Skip to content

Commit e0927eb

Browse files
authored
Merge branch 'main' into copilot/fix-health-check-postgres
2 parents d94b703 + 2713b05 commit e0927eb

11 files changed

Lines changed: 730 additions & 50 deletions

File tree

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

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System.Net;
45
using Azure.DataApiBuilder.Mcp.Model;
6+
using Azure.DataApiBuilder.Service.Exceptions;
57
using ModelContextProtocol.Protocol;
8+
using static Azure.DataApiBuilder.Mcp.Model.McpEnums;
69

710
namespace Azure.DataApiBuilder.Mcp.Core
811
{
@@ -11,15 +14,42 @@ namespace Azure.DataApiBuilder.Mcp.Core
1114
/// </summary>
1215
public class McpToolRegistry
1316
{
14-
private readonly Dictionary<string, IMcpTool> _tools = new();
17+
private readonly Dictionary<string, IMcpTool> _tools = new(StringComparer.OrdinalIgnoreCase);
1518

1619
/// <summary>
1720
/// Registers a tool in the registry
1821
/// </summary>
22+
/// <exception cref="DataApiBuilderException">Thrown when tool name is invalid or duplicate</exception>
1923
public void RegisterTool(IMcpTool tool)
2024
{
2125
Tool metadata = tool.GetToolMetadata();
22-
_tools[metadata.Name] = tool;
26+
string toolName = metadata.Name?.Trim() ?? string.Empty;
27+
28+
// Reject empty or whitespace-only tool names
29+
if (string.IsNullOrWhiteSpace(toolName))
30+
{
31+
throw new DataApiBuilderException(
32+
message: "MCP tool name cannot be null, empty, or whitespace.",
33+
statusCode: HttpStatusCode.ServiceUnavailable,
34+
subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization);
35+
}
36+
37+
// Check for duplicate tool names (case-insensitive)
38+
if (_tools.TryGetValue(toolName, out IMcpTool? existingTool))
39+
{
40+
string existingToolType = existingTool.ToolType == ToolType.BuiltIn ? "built-in" : "custom";
41+
string newToolType = tool.ToolType == ToolType.BuiltIn ? "built-in" : "custom";
42+
43+
throw new DataApiBuilderException(
44+
message: $"Duplicate MCP tool name '{toolName}' detected. " +
45+
$"A {existingToolType} tool with this name is already registered. " +
46+
$"Cannot register {newToolType} tool with the same name. " +
47+
$"Tool names must be unique across all tool types.",
48+
statusCode: HttpStatusCode.ServiceUnavailable,
49+
subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization);
50+
}
51+
52+
_tools[toolName] = tool;
2353
}
2454

2555
/// <summary>

src/Cli.Tests/ConfigureOptionsTests.cs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public class ConfigureOptionsTests : VerifyBase
1414
private MockFileSystem? _fileSystem;
1515
private FileSystemRuntimeConfigLoader? _runtimeConfigLoader;
1616
private const string TEST_RUNTIME_CONFIG_FILE = "test-update-runtime-setting.json";
17+
private const string TEST_DATASOURCE_HEALTH_NAME = "My Data Source";
1718

1819
[TestInitialize]
1920
public void TestInitialize()
@@ -955,6 +956,102 @@ public void TestFailureWhenAddingSetSessionContextToMySQLDatabase()
955956
}
956957

957958
/// <summary>
959+
/// Tests adding data-source.health.name to a config that doesn't have a health section.
960+
/// This method verifies that the health.name can be added to a data source configuration
961+
/// that doesn't previously have a health section.
962+
/// Command: dab configure --data-source.health.name "My Data Source"
963+
/// </summary>
964+
[TestMethod]
965+
public void TestAddDataSourceHealthName()
966+
{
967+
// Arrange
968+
SetupFileSystemWithInitialConfig(INITIAL_CONFIG);
969+
970+
ConfigureOptions options = new(
971+
dataSourceHealthName: TEST_DATASOURCE_HEALTH_NAME,
972+
config: TEST_RUNTIME_CONFIG_FILE
973+
);
974+
975+
// Act
976+
bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!);
977+
978+
// Assert
979+
Assert.IsTrue(isSuccess);
980+
string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
981+
Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config));
982+
Assert.IsNotNull(config.DataSource);
983+
Assert.IsNotNull(config.DataSource.Health);
984+
Assert.AreEqual(TEST_DATASOURCE_HEALTH_NAME, config.DataSource.Health.Name);
985+
Assert.IsTrue(config.DataSource.Health.Enabled); // Default value
986+
}
987+
988+
/// <summary>
989+
/// Tests updating data-source.health.name on a config that already has a health section.
990+
/// This method verifies that the health.name can be updated while preserving other health settings.
991+
/// Command: dab configure --data-source.health.name "Updated Name"
992+
/// </summary>
993+
[DataTestMethod]
994+
[DataRow("New Name", DisplayName = "Update health name with a simple string")]
995+
[DataRow("This is the value", DisplayName = "Update health name with the example from the issue")]
996+
public void TestUpdateDataSourceHealthName(string healthName)
997+
{
998+
// Arrange - Config with existing health section
999+
string configWithHealth = @"
1000+
{
1001+
""$schema"": ""test"",
1002+
""data-source"": {
1003+
""database-type"": ""mssql"",
1004+
""connection-string"": ""testconnectionstring"",
1005+
""health"": {
1006+
""enabled"": false,
1007+
""threshold-ms"": 2000
1008+
}
1009+
},
1010+
""runtime"": {
1011+
""rest"": {
1012+
""enabled"": true,
1013+
""path"": ""/api""
1014+
},
1015+
""graphql"": {
1016+
""enabled"": true,
1017+
""path"": ""/graphql"",
1018+
""allow-introspection"": true
1019+
},
1020+
""host"": {
1021+
""mode"": ""development"",
1022+
""cors"": {
1023+
""origins"": [],
1024+
""allow-credentials"": false
1025+
},
1026+
""authentication"": {
1027+
""provider"": ""StaticWebApps""
1028+
}
1029+
}
1030+
},
1031+
""entities"": {}
1032+
}";
1033+
SetupFileSystemWithInitialConfig(configWithHealth);
1034+
1035+
ConfigureOptions options = new(
1036+
dataSourceHealthName: healthName,
1037+
config: TEST_RUNTIME_CONFIG_FILE
1038+
);
1039+
1040+
// Act
1041+
bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!);
1042+
1043+
// Assert
1044+
Assert.IsTrue(isSuccess);
1045+
string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
1046+
Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config));
1047+
Assert.IsNotNull(config.DataSource);
1048+
Assert.IsNotNull(config.DataSource.Health);
1049+
Assert.AreEqual(healthName, config.DataSource.Health.Name);
1050+
// Verify existing health settings are preserved
1051+
Assert.IsFalse(config.DataSource.Health.Enabled);
1052+
Assert.AreEqual(2000, config.DataSource.Health.ThresholdMs);
1053+
}
1054+
9581055
/// Tests that running "dab configure --runtime.mcp.description {value}" on a config with various values results
9591056
/// in runtime config update. Takes in updated value for mcp.description and
9601057
/// validates whether the runtime config reflects those updated values

src/Cli/Commands/ConfigureOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public ConfigureOptions(
2828
string? dataSourceOptionsContainer = null,
2929
string? dataSourceOptionsSchema = null,
3030
bool? dataSourceOptionsSetSessionContext = null,
31+
string? dataSourceHealthName = null,
3132
int? depthLimit = null,
3233
bool? runtimeGraphQLEnabled = null,
3334
string? runtimeGraphQLPath = null,
@@ -82,6 +83,7 @@ public ConfigureOptions(
8283
DataSourceOptionsContainer = dataSourceOptionsContainer;
8384
DataSourceOptionsSchema = dataSourceOptionsSchema;
8485
DataSourceOptionsSetSessionContext = dataSourceOptionsSetSessionContext;
86+
DataSourceHealthName = dataSourceHealthName;
8587
// GraphQL
8688
DepthLimit = depthLimit;
8789
RuntimeGraphQLEnabled = runtimeGraphQLEnabled;
@@ -155,6 +157,9 @@ public ConfigureOptions(
155157
[Option("data-source.options.set-session-context", Required = false, HelpText = "Enable session context. Allowed values: true (default), false.")]
156158
public bool? DataSourceOptionsSetSessionContext { get; }
157159

160+
[Option("data-source.health.name", Required = false, HelpText = "Identifier for data source in health check report.")]
161+
public string? DataSourceHealthName { get; }
162+
158163
[Option("runtime.graphql.depth-limit", Required = false, HelpText = "Max allowed depth of the nested query. Allowed values: (0,2147483647] inclusive. Default is infinity. Use -1 to remove limit.")]
159164
public int? DepthLimit { get; }
160165

src/Cli/ConfigGenerator.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,36 @@ private static bool TryUpdateConfiguredDataSourceOptions(
684684
dbOptions.Add(namingPolicy.ConvertName(nameof(MsSqlOptions.SetSessionContext)), options.DataSourceOptionsSetSessionContext.Value);
685685
}
686686

687+
// Handle health.name option
688+
if (options.DataSourceHealthName is not null)
689+
{
690+
// If there's no existing health config, create one with the name
691+
// Note: Passing enabled: null results in Enabled = true at runtime (default behavior)
692+
// but UserProvidedEnabled = false, so the enabled property won't be serialized to JSON.
693+
// This ensures only the name property is written to the config file.
694+
if (datasourceHealthCheckConfig is null)
695+
{
696+
datasourceHealthCheckConfig = new DatasourceHealthCheckConfig(enabled: null, name: options.DataSourceHealthName);
697+
}
698+
else
699+
{
700+
// Update the existing health config with the new name while preserving other settings.
701+
// DatasourceHealthCheckConfig is a record (immutable), so we create a new instance.
702+
// Preserve threshold only if it was explicitly set by the user
703+
int? thresholdToPreserve = datasourceHealthCheckConfig.UserProvidedThresholdMs
704+
? datasourceHealthCheckConfig.ThresholdMs
705+
: null;
706+
// Preserve enabled only if it was explicitly set by the user
707+
bool? enabledToPreserve = datasourceHealthCheckConfig.UserProvidedEnabled
708+
? datasourceHealthCheckConfig.Enabled
709+
: null;
710+
datasourceHealthCheckConfig = new DatasourceHealthCheckConfig(
711+
enabled: enabledToPreserve,
712+
name: options.DataSourceHealthName,
713+
thresholdMs: thresholdToPreserve);
714+
}
715+
}
716+
687717
dbOptions = EnumerableUtilities.IsNullOrEmpty(dbOptions) ? null : dbOptions;
688718
DataSource dataSource = new(dbType, dataSourceConnectionString, dbOptions, datasourceHealthCheckConfig);
689719
runtimeConfig = runtimeConfig with { DataSource = dataSource };

src/Config/Converters/DatasourceHealthOptionsConvertorFactory.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,21 @@ public HealthCheckOptionsConverter(DeserializationVariableReplacementSettings? r
114114

115115
public override void Write(Utf8JsonWriter writer, DatasourceHealthCheckConfig value, JsonSerializerOptions options)
116116
{
117-
if (value?.UserProvidedEnabled is true)
117+
// Write the health object if any of these conditions are met:
118+
// - enabled was explicitly provided by the user
119+
// - name property has a value
120+
// - threshold was explicitly provided by the user
121+
if (value?.UserProvidedEnabled is true || value?.Name is not null || value?.UserProvidedThresholdMs is true)
118122
{
119123
writer.WriteStartObject();
120-
writer.WritePropertyName("enabled");
121-
JsonSerializer.Serialize(writer, value.Enabled, options);
124+
125+
// Only write enabled if it was explicitly provided by the user
126+
if (value?.UserProvidedEnabled is true)
127+
{
128+
writer.WritePropertyName("enabled");
129+
JsonSerializer.Serialize(writer, value.Enabled, options);
130+
}
131+
122132
if (value?.Name is not null)
123133
{
124134
writer.WritePropertyName("name");

src/Core/Configurations/RuntimeConfigValidator.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,7 @@ private void ValidateRestMethods(Entity entity, string entityName)
656656
/// <summary>
657657
/// Helper method to validate that the rest path property for the entity is correctly configured.
658658
/// The rest path should not be null/empty and should not contain any reserved characters.
659+
/// Allows sub-directories (forward slashes) in the path.
659660
/// </summary>
660661
/// <param name="entityName">Name of the entity.</param>
661662
/// <param name="pathForEntity">The rest path for the entity.</param>
@@ -672,10 +673,10 @@ private static void ValidateRestPathSettingsForEntity(string entityName, string
672673
);
673674
}
674675

675-
if (RuntimeConfigValidatorUtil.DoesUriComponentContainReservedChars(pathForEntity))
676+
if (!RuntimeConfigValidatorUtil.TryValidateEntityRestPath(pathForEntity, out string? errorMessage))
676677
{
677678
throw new DataApiBuilderException(
678-
message: $"The rest path: {pathForEntity} for entity: {entityName} contains one or more reserved characters.",
679+
message: $"The rest path: {pathForEntity} for entity: {entityName} {errorMessage ?? "contains invalid characters."}",
679680
statusCode: HttpStatusCode.ServiceUnavailable,
680681
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError
681682
);

src/Core/Configurations/RuntimeConfigValidatorUtil.cs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,94 @@ public static bool DoesUriComponentContainReservedChars(string uriComponent)
6666
return _reservedUriCharsRgx.IsMatch(uriComponent);
6767
}
6868

69+
/// <summary>
70+
/// Method to validate an entity REST path allowing sub-directories (forward slashes).
71+
/// Each segment of the path is validated for reserved characters and path traversal patterns.
72+
/// </summary>
73+
/// <param name="entityRestPath">The entity REST path to validate.</param>
74+
/// <param name="errorMessage">Output parameter containing a specific error message if validation fails.</param>
75+
/// <returns>true if the path is valid, false otherwise.</returns>
76+
public static bool TryValidateEntityRestPath(string entityRestPath, out string? errorMessage)
77+
{
78+
errorMessage = null;
79+
80+
// Check for maximum path length (reasonable limit for URL paths)
81+
const int MAX_PATH_LENGTH = 2048;
82+
if (entityRestPath.Length > MAX_PATH_LENGTH)
83+
{
84+
errorMessage = $"exceeds maximum allowed length of {MAX_PATH_LENGTH} characters.";
85+
return false;
86+
}
87+
88+
// Check for backslash usage - common mistake
89+
if (entityRestPath.Contains('\\'))
90+
{
91+
errorMessage = "contains a backslash (\\). Use forward slash (/) for path separators.";
92+
return false;
93+
}
94+
95+
// Check for percent-encoded characters (URL encoding not allowed in config)
96+
if (entityRestPath.Contains('%'))
97+
{
98+
errorMessage = "contains percent-encoding (%) which is not allowed. Use literal characters only.";
99+
return false;
100+
}
101+
102+
// Check for whitespace
103+
if (entityRestPath.Any(char.IsWhiteSpace))
104+
{
105+
errorMessage = "contains whitespace which is not allowed in URL paths.";
106+
return false;
107+
}
108+
109+
// Split the path by '/' to validate each segment separately
110+
string[] segments = entityRestPath.Split('/');
111+
112+
// Validate each segment doesn't contain reserved characters
113+
foreach (string segment in segments)
114+
{
115+
if (string.IsNullOrEmpty(segment))
116+
{
117+
errorMessage = "contains empty path segments. Ensure there are no leading, consecutive, or trailing slashes.";
118+
return false;
119+
}
120+
121+
// Check for path traversal patterns
122+
if (segment == "." || segment == "..")
123+
{
124+
errorMessage = "contains path traversal patterns ('.' or '..') which are not allowed.";
125+
return false;
126+
}
127+
128+
// Check for specific reserved characters and provide helpful messages
129+
if (segment.Contains('?'))
130+
{
131+
errorMessage = "contains '?' which is reserved for query strings in URLs.";
132+
return false;
133+
}
134+
135+
if (segment.Contains('#'))
136+
{
137+
errorMessage = "contains '#' which is reserved for URL fragments.";
138+
return false;
139+
}
140+
141+
if (segment.Contains(':'))
142+
{
143+
errorMessage = "contains ':' which is a reserved character and not allowed in URL paths.";
144+
return false;
145+
}
146+
147+
if (_reservedUriCharsRgx.IsMatch(segment))
148+
{
149+
errorMessage = "contains reserved characters that are not allowed in URL paths.";
150+
return false;
151+
}
152+
}
153+
154+
return true;
155+
}
156+
69157
/// <summary>
70158
/// Method to validate if the TTL passed by the user is valid
71159
/// </summary>

0 commit comments

Comments
 (0)