Skip to content

Commit a89db78

Browse files
Copilotsouvikghosh04Copilot
authored
Expose required array in custom MCP tool input schema (#3688)
## Why make this change? - Closes #3399 - Custom MCP tools (`DynamicCustomTool`) build an `inputSchema` with typed `properties` from stored procedure metadata, but never emitted a JSON Schema `required` array. AI agents had no way to tell which parameters are mandatory and had to fall back to description text or `describe_entities`. ## What is this change? - **`DynamicCustomTool.BuildInputSchemaFromDbMetadata`**: collects parameter names while iterating SP parameters and adds a top-level `required` array when non-empty. - **`DynamicCustomTool.BuildInputSchemaFromConfig`** (fallback path): same treatment for config-declared parameters. - **`IsParameterRequired(bool? configuredRequired, bool hasDefault)`** helper centralizes the rule: a parameter is required unless config explicitly marks it optional (`required: false`) or supplies a default the engine applies when the caller omits it. Consistent with the execution-time default-application logic and `describe_entities` semantics. Resulting schema: ```json "inputSchema": { "type": "object", "properties": { "productId": { "type": "integer" } }, "required": [ "productId" ] } ``` ## How was this tested? - [x] Integration Tests - [x] Unit Tests `DynamicCustomToolTests`: required params listed, params with defaults / `required:false` excluded, zero-param SP omits the array, config-fallback path. `DynamicCustomToolMsSqlIntegrationTests`: validates `required` against live DB metadata (`GetBook.id` required; `InsertBook` defaulted params excluded; zero-param `GetBooks` omits the array). ## Sample Request(s) MCP `tools/list` response for a custom tool over `get_book_by_id(@id int)`: ```json { "name": "get_book", "description": "Executes the get_book stored procedure", "inputSchema": { "type": "object", "properties": { "id": { "type": "integer", "description": "Parameter id" } }, "required": [ "id" ] } } ``` --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Souvik Ghosh <souvikofficial04@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 15fcfa5 commit a89db78

3 files changed

Lines changed: 267 additions & 0 deletions

File tree

src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ private JsonElement BuildInputSchema()
361361
}
362362

363363
Dictionary<string, object> properties = new();
364+
List<string> requiredParameters = new();
364365
foreach ((string paramName, ParameterDefinition paramDef) in spDefinition.Parameters)
365366
{
366367
Dictionary<string, object> paramSchema = new()
@@ -370,6 +371,13 @@ private JsonElement BuildInputSchema()
370371
};
371372

372373
properties[paramName] = paramSchema;
374+
375+
// A DB metadata parameter is required unless config marks it optional or supplies
376+
// a default the engine applies when the caller omits it.
377+
if (IsParameterRequired(paramDef.Required, paramDef.HasConfigDefault))
378+
{
379+
requiredParameters.Add(paramName);
380+
}
373381
}
374382

375383
Dictionary<string, object> schema = new()
@@ -378,6 +386,11 @@ private JsonElement BuildInputSchema()
378386
["properties"] = properties
379387
};
380388

389+
if (requiredParameters.Count > 0)
390+
{
391+
schema["required"] = requiredParameters;
392+
}
393+
381394
return JsonSerializer.SerializeToElement(schema);
382395
}
383396

@@ -396,6 +409,7 @@ private JsonElement BuildInputSchemaFromConfig()
396409
if (_entity.Source.Parameters != null && _entity.Source.Parameters.Any())
397410
{
398411
Dictionary<string, object> properties = (Dictionary<string, object>)schema["properties"];
412+
List<string> requiredParameters = new();
399413

400414
foreach (ParameterMetadata param in _entity.Source.Parameters)
401415
{
@@ -404,12 +418,40 @@ private JsonElement BuildInputSchemaFromConfig()
404418
["type"] = new[] { "string", "number", "boolean", "null" },
405419
["description"] = param.Description ?? $"Parameter {param.Name}"
406420
};
421+
422+
// A parameter is required unless config marks it optional or supplies a default.
423+
if (IsParameterRequired(param.Required, param.Default is not null))
424+
{
425+
requiredParameters.Add(param.Name);
426+
}
427+
}
428+
429+
if (requiredParameters.Count > 0)
430+
{
431+
schema["required"] = requiredParameters;
407432
}
408433
}
409434

410435
return JsonSerializer.SerializeToElement(schema);
411436
}
412437

438+
/// <summary>
439+
/// Determines whether a stored procedure parameter should be advertised as required in the
440+
/// tool input schema. A parameter is required when configuration does not mark it optional
441+
/// and does not provide a default value the engine can apply when the caller omits it.
442+
/// </summary>
443+
private static bool IsParameterRequired(bool? configuredRequired, bool hasDefault)
444+
{
445+
// If the engine can supply a default when the caller omits the parameter,
446+
// it should not be advertised as required.
447+
if (hasDefault)
448+
{
449+
return false;
450+
}
451+
452+
return configuredRequired ?? true;
453+
}
454+
413455
/// <summary>
414456
/// Maps a .NET System.Type to the appropriate JSON Schema type string.
415457
/// </summary>

src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,83 @@ public void InitializeMetadata_DescriptionIncludesConfigDefaults(string entityNa
177177
$"'{paramName}' description should mention config default '{expectedDefault}'.");
178178
}
179179

180+
/// <summary>
181+
/// Validates that a DB-discovered parameter without a config default is advertised as required.
182+
/// </summary>
183+
[TestMethod]
184+
public void InitializeMetadata_RequiredArray_IncludesParamWithoutDefault()
185+
{
186+
IServiceProvider serviceProvider = BuildQueryServiceProvider();
187+
RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService<RuntimeConfigProvider>();
188+
Entity entity = configProvider.GetConfig().Entities["GetBook"];
189+
190+
DynamicCustomTool tool = new("GetBook", entity);
191+
tool.InitializeMetadata(serviceProvider);
192+
193+
JsonElement schema = tool.GetToolMetadata().InputSchema;
194+
195+
Assert.IsTrue(schema.TryGetProperty("required", out JsonElement required),
196+
"GetBook schema should expose a 'required' array.");
197+
198+
List<string> requiredParams = new();
199+
foreach (JsonElement element in required.EnumerateArray())
200+
{
201+
requiredParams.Add(element.GetString()!);
202+
}
203+
204+
CollectionAssert.Contains(requiredParams, "id",
205+
"'id' has no config default and should be required.");
206+
}
207+
208+
/// <summary>
209+
/// Validates that parameters with config defaults (marked optional) are excluded from required.
210+
/// </summary>
211+
[TestMethod]
212+
public void InitializeMetadata_RequiredArray_ExcludesParamsWithConfigDefaults()
213+
{
214+
IServiceProvider serviceProvider = BuildQueryServiceProvider();
215+
RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService<RuntimeConfigProvider>();
216+
Entity entity = configProvider.GetConfig().Entities["InsertBook"];
217+
218+
DynamicCustomTool tool = new("InsertBook", entity);
219+
tool.InitializeMetadata(serviceProvider);
220+
221+
JsonElement schema = tool.GetToolMetadata().InputSchema;
222+
223+
List<string> requiredParams = new();
224+
if (schema.TryGetProperty("required", out JsonElement required))
225+
{
226+
foreach (JsonElement element in required.EnumerateArray())
227+
{
228+
requiredParams.Add(element.GetString()!);
229+
}
230+
}
231+
232+
CollectionAssert.DoesNotContain(requiredParams, "title",
233+
"'title' has a config default and must not be required.");
234+
CollectionAssert.DoesNotContain(requiredParams, "publisher_id",
235+
"'publisher_id' has a config default and must not be required.");
236+
}
237+
238+
/// <summary>
239+
/// Validates that a zero-parameter SP does not emit a 'required' array.
240+
/// </summary>
241+
[TestMethod]
242+
public void InitializeMetadata_ZeroParamSP_OmitsRequiredArray()
243+
{
244+
IServiceProvider serviceProvider = BuildQueryServiceProvider();
245+
RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService<RuntimeConfigProvider>();
246+
Entity entity = configProvider.GetConfig().Entities["GetBooks"];
247+
248+
DynamicCustomTool tool = new("GetBooks", entity);
249+
tool.InitializeMetadata(serviceProvider);
250+
251+
JsonElement schema = tool.GetToolMetadata().InputSchema;
252+
253+
Assert.IsFalse(schema.TryGetProperty("required", out _),
254+
"Zero-param SP should not include a 'required' array.");
255+
}
256+
180257
#endregion
181258

182259
/// <summary>

src/Service.Tests/Mcp/DynamicCustomToolTests.cs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,154 @@ public void InitializeMetadata_ZeroParamSP_ReturnsEmptyProperties()
710710
Assert.AreEqual(0, props.EnumerateObject().Count());
711711
}
712712

713+
/// <summary>
714+
/// DB-discovered parameters with no config default/override are advertised as required.
715+
/// </summary>
716+
[TestMethod]
717+
public void InitializeMetadata_IncludesRequiredArray_ForParamsWithoutDefaults()
718+
{
719+
// Arrange
720+
Dictionary<string, ParameterDefinition> dbParams = new()
721+
{
722+
["productId"] = new() { SystemType = typeof(int) }
723+
};
724+
725+
// Act
726+
JsonElement schema = InitializeAndGetSchema(dbParams);
727+
728+
// Assert
729+
Assert.IsTrue(schema.TryGetProperty("required", out JsonElement required),
730+
"Schema should expose a 'required' array for parameters without defaults.");
731+
CollectionAssert.AreEquivalent(
732+
new[] { "productId" },
733+
EnumerateStrings(required),
734+
"'productId' should be listed as required.");
735+
}
736+
737+
/// <summary>
738+
/// Parameters that have a config default or are explicitly marked optional are excluded
739+
/// from the required array, while parameters without defaults remain required.
740+
/// </summary>
741+
[TestMethod]
742+
public void InitializeMetadata_ExcludesParamsWithDefaultsOrOptionalFlag_FromRequired()
743+
{
744+
// Arrange
745+
Dictionary<string, ParameterDefinition> dbParams = new()
746+
{
747+
["id"] = new() { SystemType = typeof(int) },
748+
["title"] = new() { SystemType = typeof(string), HasConfigDefault = true, ConfigDefaultValue = "randomX" },
749+
["category"] = new() { SystemType = typeof(string), Required = true, HasConfigDefault = true, ConfigDefaultValue = "defaultCategory" },
750+
["publisher_id"] = new() { SystemType = typeof(int), Required = false }
751+
};
752+
753+
// Act
754+
JsonElement schema = InitializeAndGetSchema(dbParams);
755+
756+
// Assert
757+
Assert.IsTrue(schema.TryGetProperty("required", out JsonElement required));
758+
CollectionAssert.AreEquivalent(
759+
new[] { "id" },
760+
EnumerateStrings(required),
761+
"Only 'id' (no default, not marked optional) should be required.");
762+
}
763+
764+
/// <summary>
765+
/// A zero-parameter SP must not emit a 'required' array.
766+
/// </summary>
767+
[TestMethod]
768+
public void InitializeMetadata_ZeroParamSP_OmitsRequiredArray()
769+
{
770+
// Arrange & Act
771+
JsonElement schema = InitializeAndGetSchema(new Dictionary<string, ParameterDefinition>());
772+
773+
// Assert
774+
Assert.IsFalse(schema.TryGetProperty("required", out _),
775+
"Zero-param SP should not include a 'required' array.");
776+
}
777+
778+
/// <summary>
779+
/// When falling back to config-based schema, parameters without a default are required.
780+
/// </summary>
781+
[TestMethod]
782+
public void GetToolMetadata_ConfigFallback_MarksParamsWithoutDefaultsRequired()
783+
{
784+
// Arrange - config declares one required param and one with a default
785+
ParameterMetadata[] parameters = new[]
786+
{
787+
new ParameterMetadata { Name = "userId" },
788+
new ParameterMetadata { Name = "tenant", Default = "contoso" }
789+
};
790+
Entity entity = CreateTestStoredProcedureEntity(parameters: parameters);
791+
DynamicCustomTool tool = new("GetUser", entity);
792+
793+
// Act - no InitializeMetadata call, so the config-based schema is used
794+
JsonElement schema = tool.GetToolMetadata().InputSchema;
795+
796+
// Assert
797+
Assert.IsTrue(schema.TryGetProperty("required", out JsonElement required));
798+
CollectionAssert.AreEquivalent(
799+
new[] { "userId" },
800+
EnumerateStrings(required),
801+
"Only 'userId' (no default) should be required in the config-based schema.");
802+
}
803+
804+
/// <summary>
805+
/// Config fallback should exclude parameters with defaults from the required array even when
806+
/// they are explicitly marked required.
807+
/// </summary>
808+
[TestMethod]
809+
public void GetToolMetadata_ConfigFallback_ExcludesRequiredParamWhenDefaultExists()
810+
{
811+
// Arrange
812+
ParameterMetadata[] parameters = new[]
813+
{
814+
new ParameterMetadata { Name = "userId", Required = true },
815+
new ParameterMetadata { Name = "tenant", Required = true, Default = "contoso" }
816+
};
817+
Entity entity = CreateTestStoredProcedureEntity(parameters: parameters);
818+
DynamicCustomTool tool = new("GetUser", entity);
819+
820+
// Act
821+
JsonElement schema = tool.GetToolMetadata().InputSchema;
822+
823+
// Assert
824+
Assert.IsTrue(schema.TryGetProperty("required", out JsonElement required));
825+
CollectionAssert.AreEquivalent(
826+
new[] { "userId" },
827+
EnumerateStrings(required),
828+
"Parameters with config defaults should not be required even when marked required.");
829+
}
830+
831+
/// <summary>
832+
/// Helper: Parses the "required" array values into a list of strings.
833+
/// </summary>
834+
private static List<string> EnumerateStrings(JsonElement array)
835+
{
836+
List<string> values = new();
837+
foreach (JsonElement element in array.EnumerateArray())
838+
{
839+
values.Add(element.GetString()!);
840+
}
841+
842+
return values;
843+
}
844+
845+
/// <summary>
846+
/// Helper: Creates a DynamicCustomTool, initializes it with mocked DB metadata, and returns
847+
/// the full input schema element.
848+
/// </summary>
849+
private static JsonElement InitializeAndGetSchema(
850+
Dictionary<string, ParameterDefinition> dbParameters,
851+
string entityName = "TestSP")
852+
{
853+
Entity entity = CreateTestStoredProcedureEntity();
854+
DynamicCustomTool tool = new(entityName, entity);
855+
IServiceProvider sp = BuildServiceProviderForMetadata(entityName, dbParameters);
856+
857+
tool.InitializeMetadata(sp);
858+
return tool.GetToolMetadata().InputSchema;
859+
}
860+
713861
/// <summary>
714862
/// Helper: Creates a DynamicCustomTool, initializes it with mocked DB metadata, and returns
715863
/// the "properties" element from the resulting input schema.

0 commit comments

Comments
 (0)