Skip to content

Commit 479a235

Browse files
authored
Expose parameter required array in custom tool input schema
1 parent e662a6c commit 479a235

3 files changed

Lines changed: 238 additions & 0 deletions

File tree

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

Lines changed: 41 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 parameter is required unless config marks it optional or supplies a default
376+
// 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,39 @@ 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 (configuredRequired is not null)
446+
{
447+
return configuredRequired.Value;
448+
}
449+
450+
// No explicit config: a parameter is required unless a default is available.
451+
return !hasDefault;
452+
}
453+
413454
/// <summary>
414455
/// Maps a .NET System.Type to the appropriate JSON Schema type string.
415456
/// </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: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,126 @@ 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+
["publisher_id"] = new() { SystemType = typeof(int), Required = false }
750+
};
751+
752+
// Act
753+
JsonElement schema = InitializeAndGetSchema(dbParams);
754+
755+
// Assert
756+
Assert.IsTrue(schema.TryGetProperty("required", out JsonElement required));
757+
CollectionAssert.AreEquivalent(
758+
new[] { "id" },
759+
EnumerateStrings(required),
760+
"Only 'id' (no default, not marked optional) should be required.");
761+
}
762+
763+
/// <summary>
764+
/// A zero-parameter SP must not emit a 'required' array.
765+
/// </summary>
766+
[TestMethod]
767+
public void InitializeMetadata_ZeroParamSP_OmitsRequiredArray()
768+
{
769+
// Arrange & Act
770+
JsonElement schema = InitializeAndGetSchema(new Dictionary<string, ParameterDefinition>());
771+
772+
// Assert
773+
Assert.IsFalse(schema.TryGetProperty("required", out _),
774+
"Zero-param SP should not include a 'required' array.");
775+
}
776+
777+
/// <summary>
778+
/// When falling back to config-based schema, parameters without a default are required.
779+
/// </summary>
780+
[TestMethod]
781+
public void GetToolMetadata_ConfigFallback_MarksParamsWithoutDefaultsRequired()
782+
{
783+
// Arrange - config declares one required param and one with a default
784+
ParameterMetadata[] parameters = new[]
785+
{
786+
new ParameterMetadata { Name = "userId" },
787+
new ParameterMetadata { Name = "tenant", Default = "contoso" }
788+
};
789+
Entity entity = CreateTestStoredProcedureEntity(parameters: parameters);
790+
DynamicCustomTool tool = new("GetUser", entity);
791+
792+
// Act - no InitializeMetadata call, so the config-based schema is used
793+
JsonElement schema = tool.GetToolMetadata().InputSchema;
794+
795+
// Assert
796+
Assert.IsTrue(schema.TryGetProperty("required", out JsonElement required));
797+
CollectionAssert.AreEquivalent(
798+
new[] { "userId" },
799+
EnumerateStrings(required),
800+
"Only 'userId' (no default) should be required in the config-based schema.");
801+
}
802+
803+
/// <summary>
804+
/// Helper: Parses the "required" array values into a list of strings.
805+
/// </summary>
806+
private static List<string> EnumerateStrings(JsonElement array)
807+
{
808+
List<string> values = new();
809+
foreach (JsonElement element in array.EnumerateArray())
810+
{
811+
values.Add(element.GetString()!);
812+
}
813+
814+
return values;
815+
}
816+
817+
/// <summary>
818+
/// Helper: Creates a DynamicCustomTool, initializes it with mocked DB metadata, and returns
819+
/// the full input schema element.
820+
/// </summary>
821+
private static JsonElement InitializeAndGetSchema(
822+
Dictionary<string, ParameterDefinition> dbParameters,
823+
string entityName = "TestSP")
824+
{
825+
Entity entity = CreateTestStoredProcedureEntity();
826+
DynamicCustomTool tool = new(entityName, entity);
827+
IServiceProvider sp = BuildServiceProviderForMetadata(entityName, dbParameters);
828+
829+
tool.InitializeMetadata(sp);
830+
return tool.GetToolMetadata().InputSchema;
831+
}
832+
713833
/// <summary>
714834
/// Helper: Creates a DynamicCustomTool, initializes it with mocked DB metadata, and returns
715835
/// the "properties" element from the resulting input schema.

0 commit comments

Comments
 (0)