From 3f97416a5518ad498446b18e6917da60cd58fba4 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 4 Dec 2025 16:15:41 +0530 Subject: [PATCH 01/15] Add entity-level MCP configuration support - Add EntityMcpOptions class with custom-tool and dml-tools properties - Add JSON converter supporting boolean and object formats - Add CLI support for --mcp.dml-tools and --mcp.custom-tool flags - Add schema validation restricting custom-tool to stored procedures - Entity.Mcp property is optional (default null) to avoid test cascade Only 9 files changed in this minimal implementation. --- schemas/dab.draft.schema.json | 52 +++++++ src/Cli/Commands/AddOptions.cs | 6 +- src/Cli/Commands/EntityOptions.cs | 12 +- src/Cli/Commands/UpdateOptions.cs | 6 +- src/Cli/ConfigGenerator.cs | 27 +++- src/Cli/Utils.cs | 50 +++++++ .../EntityMcpOptionsConverterFactory.cs | 127 ++++++++++++++++++ src/Config/ObjectModel/Entity.cs | 8 +- src/Config/ObjectModel/EntityMcpOptions.cs | 62 +++++++++ 9 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 src/Config/Converters/EntityMcpOptionsConverterFactory.cs create mode 100644 src/Config/ObjectModel/EntityMcpOptions.cs diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 80cfd953ad..54db9dbaba 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -961,6 +961,31 @@ "default": 5 } } + }, + "mcp": { + "oneOf": [ + { + "type": "boolean", + "description": "Boolean shorthand: true enables dml-tools, false disables dml-tools." + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "dml-tools": { + "type": "boolean", + "description": "Enable MCP DML (Data Manipulation Language) tools for this entity. Allows CRUD operations via MCP.", + "default": true + }, + "custom-tool": { + "type": "boolean", + "description": "Enable MCP custom tool for this entity. Only valid for stored procedures.", + "default": false + } + } + } + ], + "description": "Model Context Protocol (MCP) configuration for this entity. Controls whether the entity is exposed via MCP tools." } }, "if": { @@ -1145,6 +1170,33 @@ ] } } + }, + { + "if": { + "properties": { + "mcp": { + "properties": { + "custom-tool": { + "const": true + } + } + } + }, + "required": ["mcp"] + }, + "then": { + "properties": { + "source": { + "properties": { + "type": { + "const": "stored-procedure" + } + }, + "required": ["type"] + } + }, + "errorMessage": "custom-tool can only be enabled for entities with source type 'stored-procedure'" + } } ] } diff --git a/src/Cli/Commands/AddOptions.cs b/src/Cli/Commands/AddOptions.cs index b7d9fbeb08..e7e378d94b 100644 --- a/src/Cli/Commands/AddOptions.cs +++ b/src/Cli/Commands/AddOptions.cs @@ -43,7 +43,9 @@ public AddOptions( IEnumerable? fieldsAliasCollection, IEnumerable? fieldsDescriptionCollection, IEnumerable? fieldsPrimaryKeyCollection, - string? config + string? mcpDmlTools = null, + string? mcpCustomTool = null, + string? config = null ) : base( entity, @@ -69,6 +71,8 @@ public AddOptions( fieldsAliasCollection, fieldsDescriptionCollection, fieldsPrimaryKeyCollection, + mcpDmlTools, + mcpCustomTool, config ) { diff --git a/src/Cli/Commands/EntityOptions.cs b/src/Cli/Commands/EntityOptions.cs index 7f26816800..700bb051eb 100644 --- a/src/Cli/Commands/EntityOptions.cs +++ b/src/Cli/Commands/EntityOptions.cs @@ -34,7 +34,9 @@ public EntityOptions( IEnumerable? fieldsAliasCollection, IEnumerable? fieldsDescriptionCollection, IEnumerable? fieldsPrimaryKeyCollection, - string? config + string? mcpDmlTools = null, + string? mcpCustomTool = null, + string? config = null ) : base(config) { @@ -61,6 +63,8 @@ public EntityOptions( FieldsAliasCollection = fieldsAliasCollection; FieldsDescriptionCollection = fieldsDescriptionCollection; FieldsPrimaryKeyCollection = fieldsPrimaryKeyCollection; + McpDmlTools = mcpDmlTools; + McpCustomTool = mcpCustomTool; } // Entity is required but we have made required as false to have custom error message (more user friendly), if not provided. @@ -132,5 +136,11 @@ public EntityOptions( [Option("fields.primary-key", Required = false, Separator = ',', HelpText = "Set this field as a primary key.")] public IEnumerable? FieldsPrimaryKeyCollection { get; } + + [Option("mcp.dml-tools", Required = false, HelpText = "Enable MCP DML (Data Manipulation Language) tools for this entity. Allows CRUD operations via MCP.")] + public string? McpDmlTools { get; } + + [Option("mcp.custom-tool", Required = false, HelpText = "Enable MCP custom tool for this entity. Only valid for stored procedures.")] + public string? McpCustomTool { get; } } } diff --git a/src/Cli/Commands/UpdateOptions.cs b/src/Cli/Commands/UpdateOptions.cs index fe1664c5bb..050afa2ddb 100644 --- a/src/Cli/Commands/UpdateOptions.cs +++ b/src/Cli/Commands/UpdateOptions.cs @@ -51,7 +51,9 @@ public UpdateOptions( IEnumerable? fieldsAliasCollection, IEnumerable? fieldsDescriptionCollection, IEnumerable? fieldsPrimaryKeyCollection, - string? config) + string? mcpDmlTools = null, + string? mcpCustomTool = null, + string? config = null) : base(entity, sourceType, sourceParameters, @@ -75,6 +77,8 @@ public UpdateOptions( fieldsAliasCollection, fieldsDescriptionCollection, fieldsPrimaryKeyCollection, + mcpDmlTools, + mcpCustomTool, config) { Source = source; diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 7c35335089..a2802c28c2 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -449,6 +449,18 @@ public static bool TryAddNewEntity(AddOptions options, RuntimeConfig initialRunt EntityRestOptions restOptions = ConstructRestOptions(options.RestRoute, SupportedRestMethods, initialRuntimeConfig.DataSource.DatabaseType == DatabaseType.CosmosDB_NoSQL); EntityGraphQLOptions graphqlOptions = ConstructGraphQLTypeDetails(options.GraphQLType, graphQLOperationsForStoredProcedures); EntityCacheOptions? cacheOptions = ConstructCacheOptions(options.CacheEnabled, options.CacheTtl); + + if (options.McpDmlTools is not null || options.McpCustomTool is not null) + { + EntityMcpOptions? mcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedure); + if (mcpOptions is null) + { + _logger.LogError("Failed to construct MCP options."); + return false; + } + } + + EntityMcpOptions? mcpOptionsToUse = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedure); // Create new entity. Entity entity = new( @@ -460,7 +472,8 @@ public static bool TryAddNewEntity(AddOptions options, RuntimeConfig initialRunt Relationships: null, Mappings: null, Cache: cacheOptions, - Description: string.IsNullOrWhiteSpace(options.Description) ? null : options.Description); + Description: string.IsNullOrWhiteSpace(options.Description) ? null : options.Description, + Mcp: mcpOptionsToUse); // Add entity to existing runtime config. IDictionary entities = new Dictionary(initialRuntimeConfig.Entities.Entities) @@ -1620,6 +1633,15 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, RuntimeConfig EntityActionPolicy? updatedPolicy = GetPolicyForOperation(options.PolicyRequest, options.PolicyDatabase); EntityActionFields? updatedFields = GetFieldsForOperation(options.FieldsToInclude, options.FieldsToExclude); EntityCacheOptions? updatedCacheOptions = ConstructCacheOptions(options.CacheEnabled, options.CacheTtl); + + // Determine if the entity is or will be a stored procedure + bool isStoredProcedureAfterUpdate = doOptionsRepresentStoredProcedure || (isCurrentEntityStoredProcedure && options.SourceType is null); + EntityMcpOptions? updatedMcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedureAfterUpdate); + // If MCP options were provided, use them; otherwise keep existing MCP options + if (updatedMcpOptions is null) + { + updatedMcpOptions = entity.Mcp; + } if (!updatedGraphQLDetails.Enabled) { @@ -1857,7 +1879,8 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, RuntimeConfig Relationships: updatedRelationships, Mappings: updatedMappings, Cache: updatedCacheOptions, - Description: string.IsNullOrWhiteSpace(options.Description) ? entity.Description : options.Description + Description: string.IsNullOrWhiteSpace(options.Description) ? entity.Description : options.Description, + Mcp: updatedMcpOptions ); IDictionary entities = new Dictionary(initialConfig.Entities.Entities) { diff --git a/src/Cli/Utils.cs b/src/Cli/Utils.cs index 451c330503..5a52d4cb0d 100644 --- a/src/Cli/Utils.cs +++ b/src/Cli/Utils.cs @@ -892,6 +892,56 @@ public static EntityGraphQLOptions ConstructGraphQLTypeDetails(string? graphQL, return cacheOptions with { Enabled = isEnabled, TtlSeconds = ttl, UserProvidedTtlOptions = isCacheTtlUserProvided }; } + /// + /// Constructs the EntityMcpOptions for Add/Update. + /// + /// String value that defines if DML tools are enabled for MCP. + /// String value that defines if custom tool is enabled for MCP. + /// Whether the entity is a stored procedure. + /// EntityMcpOptions if values are provided, null otherwise. + public static EntityMcpOptions? ConstructMcpOptions(string? mcpDmlTools, string? mcpCustomTool, bool isStoredProcedure) + { + if (mcpDmlTools is null && mcpCustomTool is null) + { + return null; + } + + bool? dmlToolsEnabled = null; + bool? customToolEnabled = null; + + // Parse dml-tools option + if (mcpDmlTools is not null) + { + if (!bool.TryParse(mcpDmlTools, out bool dmlValue)) + { + _logger.LogError("Invalid format for --mcp.dml-tools. Accepted values are true/false."); + return null; + } + dmlToolsEnabled = dmlValue; + } + + // Parse custom-tool option + if (mcpCustomTool is not null) + { + if (!bool.TryParse(mcpCustomTool, out bool customValue)) + { + _logger.LogError("Invalid format for --mcp.custom-tool. Accepted values are true/false."); + return null; + } + + // Validate that custom-tool can only be used with stored procedures + if (customValue && !isStoredProcedure) + { + _logger.LogError("--mcp.custom-tool can only be enabled for stored procedures."); + return null; + } + + customToolEnabled = customValue; + } + + return new EntityMcpOptions(customToolEnabled, dmlToolsEnabled); + } + /// /// Check if add/update command has Entity provided. Return false otherwise. /// diff --git a/src/Config/Converters/EntityMcpOptionsConverterFactory.cs b/src/Config/Converters/EntityMcpOptionsConverterFactory.cs new file mode 100644 index 0000000000..25d0c9487f --- /dev/null +++ b/src/Config/Converters/EntityMcpOptionsConverterFactory.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +/// +/// Factory for creating EntityMcpOptions converters. +/// +internal class EntityMcpOptionsConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert == typeof(EntityMcpOptions); + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + return new EntityMcpOptionsConverter(); + } + + /// + /// Converter for EntityMcpOptions that handles both boolean and object representations. + /// When boolean: true enables dml-tools, false disables dml-tools. + /// When object: can specify individual properties (custom-tool and dml-tools). + /// + private class EntityMcpOptionsConverter : JsonConverter + { + public override EntityMcpOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + // Handle boolean shorthand: true/false + if (reader.TokenType == JsonTokenType.True || reader.TokenType == JsonTokenType.False) + { + bool value = reader.GetBoolean(); + // Boolean true means: dml-tools=true, custom-tool=false (default) + // Boolean false means: dml-tools=false, custom-tool=false + return new EntityMcpOptions( + customToolEnabled: false, + dmlToolsEnabled: value + ); + } + + // Handle object representation + if (reader.TokenType == JsonTokenType.StartObject) + { + bool? customToolEnabled = null; + bool? dmlToolsEnabled = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType == JsonTokenType.PropertyName) + { + string? propertyName = reader.GetString(); + reader.Read(); // Move to the value + + if (propertyName == "custom-tool") + { + customToolEnabled = reader.TokenType == JsonTokenType.True; + } + else if (propertyName == "dml-tools") + { + dmlToolsEnabled = reader.TokenType == JsonTokenType.True; + } + } + } + + return new EntityMcpOptions(customToolEnabled, dmlToolsEnabled); + } + + throw new JsonException($"Unexpected token type {reader.TokenType} for EntityMcpOptions"); + } + + public override void Write(Utf8JsonWriter writer, EntityMcpOptions value, JsonSerializerOptions options) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + // Check if we should write as boolean shorthand + // Write as boolean if: only dml-tools is set (or custom-tool is default false) + bool writeAsBoolean = !value.UserProvidedCustomToolEnabled && value.UserProvidedDmlToolsEnabled; + + if (writeAsBoolean) + { + // Write as boolean shorthand + writer.WriteBooleanValue(value.DmlToolEnabled ?? true); + } + else if (value.UserProvidedCustomToolEnabled || value.UserProvidedDmlToolsEnabled) + { + // Write as object + writer.WriteStartObject(); + + if (value.UserProvidedCustomToolEnabled) + { + writer.WriteBoolean("custom-tool", value.CustomToolEnabled ?? false); + } + + if (value.UserProvidedDmlToolsEnabled) + { + writer.WriteBoolean("dml-tools", value.DmlToolEnabled ?? true); + } + + writer.WriteEndObject(); + } + else + { + // Nothing provided, write null (will be omitted by DefaultIgnoreCondition) + writer.WriteNullValue(); + } + } + } +} diff --git a/src/Config/ObjectModel/Entity.cs b/src/Config/ObjectModel/Entity.cs index c9f247e0f6..1e8c5a6dba 100644 --- a/src/Config/ObjectModel/Entity.cs +++ b/src/Config/ObjectModel/Entity.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.Converters; using Azure.DataApiBuilder.Config.HealthCheck; namespace Azure.DataApiBuilder.Config.ObjectModel; @@ -39,6 +40,9 @@ public record Entity public EntityCacheOptions? Cache { get; init; } public EntityHealthCheckConfig? Health { get; init; } + [JsonConverter(typeof(EntityMcpOptionsConverterFactory))] + public EntityMcpOptions? Mcp { get; init; } + [JsonIgnore] public bool IsLinkingEntity { get; init; } @@ -54,7 +58,8 @@ public Entity( EntityCacheOptions? Cache = null, bool IsLinkingEntity = false, EntityHealthCheckConfig? Health = null, - string? Description = null) + string? Description = null, + EntityMcpOptions? Mcp = null) { this.Health = Health; this.Source = Source; @@ -67,6 +72,7 @@ public Entity( this.Cache = Cache; this.IsLinkingEntity = IsLinkingEntity; this.Description = Description; + this.Mcp = Mcp; } /// diff --git a/src/Config/ObjectModel/EntityMcpOptions.cs b/src/Config/ObjectModel/EntityMcpOptions.cs new file mode 100644 index 0000000000..d231e2ab0c --- /dev/null +++ b/src/Config/ObjectModel/EntityMcpOptions.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel +{ + /// + /// Options for Model Context Protocol (MCP) tools at the entity level. + /// + public record EntityMcpOptions + { + /// + /// Indicates whether custom tools are enabled for this entity. + /// Only applicable for stored procedures. + /// + [JsonPropertyName("custom-tool")] + public bool? CustomToolEnabled { get; init; } = false; + + /// + /// Indicates whether DML tools are enabled for this entity. + /// + [JsonPropertyName("dml-tools")] + public bool? DmlToolEnabled { get; init; } = true; + + /// + /// Flag which informs CLI and JSON serializer whether to write the CustomToolEnabled + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool UserProvidedCustomToolEnabled { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write the DmlToolEnabled + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool UserProvidedDmlToolsEnabled { get; init; } = false; + + /// + /// Constructor for EntityMcpOptions + /// + /// The custom tool enabled flag. + /// The DML tools enabled flag. + public EntityMcpOptions(bool? customToolEnabled, bool? dmlToolsEnabled) + { + if (customToolEnabled is not null) + { + this.CustomToolEnabled = customToolEnabled; + this.UserProvidedCustomToolEnabled = true; + } + + if (dmlToolsEnabled is not null) + { + this.DmlToolEnabled = dmlToolsEnabled; + this.UserProvidedDmlToolsEnabled = true; + } + else + { + this.DmlToolEnabled = true; + } + } + } +} From 8bde11e6a10bd983956dd97a2b6b1a774bb850d4 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 4 Dec 2025 17:18:57 +0530 Subject: [PATCH 02/15] Address PR review comments - Update EntityMcpOptions documentation to clarify custom-tool behavior in boolean mode - Replace if-else with switch-case in converter for better extensibility - Remove unnecessary null writes in serializer - Change CustomToolEnabled and DmlToolEnabled from nullable to non-nullable bool - Fix boolean shorthand deserialization to not mark custom-tool as user-provided - Add consistent else block in constructor for symmetry All 530 tests passing. Functionality verified with manual testing. --- schemas/dab.draft.schema.json | 2 +- src/Cli/ConfigGenerator.cs | 7 ++-- src/Cli/Utils.cs | 1 + .../EntityMcpOptionsConverterFactory.cs | 32 +++++++++---------- src/Config/ObjectModel/EntityMcpOptions.cs | 16 ++++------ 5 files changed, 27 insertions(+), 31 deletions(-) diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 54db9dbaba..01e3a874f0 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -695,7 +695,7 @@ }, "entities": { "type": "object", - "description": "Entities that will be exposed via REST and/or GraphQL", + "description": "Entities that will be exposed via REST, GraphQL and/or MCP", "patternProperties": { "^.*$": { "type": "object", diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index a2802c28c2..c3d747680c 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -449,7 +449,7 @@ public static bool TryAddNewEntity(AddOptions options, RuntimeConfig initialRunt EntityRestOptions restOptions = ConstructRestOptions(options.RestRoute, SupportedRestMethods, initialRuntimeConfig.DataSource.DatabaseType == DatabaseType.CosmosDB_NoSQL); EntityGraphQLOptions graphqlOptions = ConstructGraphQLTypeDetails(options.GraphQLType, graphQLOperationsForStoredProcedures); EntityCacheOptions? cacheOptions = ConstructCacheOptions(options.CacheEnabled, options.CacheTtl); - + if (options.McpDmlTools is not null || options.McpCustomTool is not null) { EntityMcpOptions? mcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedure); @@ -459,7 +459,7 @@ public static bool TryAddNewEntity(AddOptions options, RuntimeConfig initialRunt return false; } } - + EntityMcpOptions? mcpOptionsToUse = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedure); // Create new entity. @@ -1633,10 +1633,11 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, RuntimeConfig EntityActionPolicy? updatedPolicy = GetPolicyForOperation(options.PolicyRequest, options.PolicyDatabase); EntityActionFields? updatedFields = GetFieldsForOperation(options.FieldsToInclude, options.FieldsToExclude); EntityCacheOptions? updatedCacheOptions = ConstructCacheOptions(options.CacheEnabled, options.CacheTtl); - + // Determine if the entity is or will be a stored procedure bool isStoredProcedureAfterUpdate = doOptionsRepresentStoredProcedure || (isCurrentEntityStoredProcedure && options.SourceType is null); EntityMcpOptions? updatedMcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedureAfterUpdate); + // If MCP options were provided, use them; otherwise keep existing MCP options if (updatedMcpOptions is null) { diff --git a/src/Cli/Utils.cs b/src/Cli/Utils.cs index 5a52d4cb0d..48edd4411c 100644 --- a/src/Cli/Utils.cs +++ b/src/Cli/Utils.cs @@ -917,6 +917,7 @@ public static EntityGraphQLOptions ConstructGraphQLTypeDetails(string? graphQL, _logger.LogError("Invalid format for --mcp.dml-tools. Accepted values are true/false."); return null; } + dmlToolsEnabled = dmlValue; } diff --git a/src/Config/Converters/EntityMcpOptionsConverterFactory.cs b/src/Config/Converters/EntityMcpOptionsConverterFactory.cs index 25d0c9487f..2c4d4d44df 100644 --- a/src/Config/Converters/EntityMcpOptionsConverterFactory.cs +++ b/src/Config/Converters/EntityMcpOptionsConverterFactory.cs @@ -24,7 +24,7 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer /// /// Converter for EntityMcpOptions that handles both boolean and object representations. - /// When boolean: true enables dml-tools, false disables dml-tools. + /// When boolean: true enables dml-tools and custom-tool remains false (default), false disables dml-tools and custom-tool remains false. /// When object: can specify individual properties (custom-tool and dml-tools). /// private class EntityMcpOptionsConverter : JsonConverter @@ -42,8 +42,9 @@ private class EntityMcpOptionsConverter : JsonConverter bool value = reader.GetBoolean(); // Boolean true means: dml-tools=true, custom-tool=false (default) // Boolean false means: dml-tools=false, custom-tool=false + // Pass null for customToolEnabled to keep it as default (not user-provided) return new EntityMcpOptions( - customToolEnabled: false, + customToolEnabled: null, dmlToolsEnabled: value ); } @@ -66,13 +67,16 @@ private class EntityMcpOptionsConverter : JsonConverter string? propertyName = reader.GetString(); reader.Read(); // Move to the value - if (propertyName == "custom-tool") + switch (propertyName) { - customToolEnabled = reader.TokenType == JsonTokenType.True; - } - else if (propertyName == "dml-tools") - { - dmlToolsEnabled = reader.TokenType == JsonTokenType.True; + case "custom-tool": + customToolEnabled = reader.TokenType == JsonTokenType.True; + break; + case "dml-tools": + dmlToolsEnabled = reader.TokenType == JsonTokenType.True; + break; + default: + throw new JsonException($"Unknown property '{propertyName}' in EntityMcpOptions"); } } } @@ -87,7 +91,6 @@ public override void Write(Utf8JsonWriter writer, EntityMcpOptions value, JsonSe { if (value == null) { - writer.WriteNullValue(); return; } @@ -98,7 +101,7 @@ public override void Write(Utf8JsonWriter writer, EntityMcpOptions value, JsonSe if (writeAsBoolean) { // Write as boolean shorthand - writer.WriteBooleanValue(value.DmlToolEnabled ?? true); + writer.WriteBooleanValue(value.DmlToolEnabled); } else if (value.UserProvidedCustomToolEnabled || value.UserProvidedDmlToolsEnabled) { @@ -107,21 +110,16 @@ public override void Write(Utf8JsonWriter writer, EntityMcpOptions value, JsonSe if (value.UserProvidedCustomToolEnabled) { - writer.WriteBoolean("custom-tool", value.CustomToolEnabled ?? false); + writer.WriteBoolean("custom-tool", value.CustomToolEnabled); } if (value.UserProvidedDmlToolsEnabled) { - writer.WriteBoolean("dml-tools", value.DmlToolEnabled ?? true); + writer.WriteBoolean("dml-tools", value.DmlToolEnabled); } writer.WriteEndObject(); } - else - { - // Nothing provided, write null (will be omitted by DefaultIgnoreCondition) - writer.WriteNullValue(); - } } } } diff --git a/src/Config/ObjectModel/EntityMcpOptions.cs b/src/Config/ObjectModel/EntityMcpOptions.cs index d231e2ab0c..b72d1d3a09 100644 --- a/src/Config/ObjectModel/EntityMcpOptions.cs +++ b/src/Config/ObjectModel/EntityMcpOptions.cs @@ -15,13 +15,13 @@ public record EntityMcpOptions /// Only applicable for stored procedures. /// [JsonPropertyName("custom-tool")] - public bool? CustomToolEnabled { get; init; } = false; + public bool CustomToolEnabled { get; init; } = false; /// /// Indicates whether DML tools are enabled for this entity. /// [JsonPropertyName("dml-tools")] - public bool? DmlToolEnabled { get; init; } = true; + public bool DmlToolEnabled { get; init; } = true; /// /// Flag which informs CLI and JSON serializer whether to write the CustomToolEnabled @@ -42,21 +42,17 @@ public record EntityMcpOptions /// The DML tools enabled flag. public EntityMcpOptions(bool? customToolEnabled, bool? dmlToolsEnabled) { - if (customToolEnabled is not null) + if (customToolEnabled.HasValue) { - this.CustomToolEnabled = customToolEnabled; + this.CustomToolEnabled = customToolEnabled.Value; this.UserProvidedCustomToolEnabled = true; } - if (dmlToolsEnabled is not null) + if (dmlToolsEnabled.HasValue) { - this.DmlToolEnabled = dmlToolsEnabled; + this.DmlToolEnabled = dmlToolsEnabled.Value; this.UserProvidedDmlToolsEnabled = true; } - else - { - this.DmlToolEnabled = true; - } } } } From c186dc280c0fbeb16a50bb13a3435fb04ba78784 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Fri, 5 Dec 2025 10:11:56 +0530 Subject: [PATCH 03/15] Validate MCP options if provided --- src/Cli/ConfigGenerator.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index c3d747680c..957c7903b7 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -1636,6 +1636,19 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, RuntimeConfig // Determine if the entity is or will be a stored procedure bool isStoredProcedureAfterUpdate = doOptionsRepresentStoredProcedure || (isCurrentEntityStoredProcedure && options.SourceType is null); + + // Validate MCP options if provided + if (options.McpDmlTools is not null || options.McpCustomTool is not null) + { + EntityMcpOptions? mcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedureAfterUpdate); + + if (mcpOptions is null) + { + _logger.LogError("Failed to construct MCP options."); + return false; + } + } + EntityMcpOptions? updatedMcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedureAfterUpdate); // If MCP options were provided, use them; otherwise keep existing MCP options From 0514f58627d52749e1863a913012a0bb63bfb9af Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Fri, 5 Dec 2025 15:02:37 +0530 Subject: [PATCH 04/15] Copilot review fixes --- src/Cli/ConfigGenerator.cs | 22 ++++++++----------- .../EntityMcpOptionsConverterFactory.cs | 4 ++-- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 957c7903b7..f8fdd66560 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -450,18 +450,17 @@ public static bool TryAddNewEntity(AddOptions options, RuntimeConfig initialRunt EntityGraphQLOptions graphqlOptions = ConstructGraphQLTypeDetails(options.GraphQLType, graphQLOperationsForStoredProcedures); EntityCacheOptions? cacheOptions = ConstructCacheOptions(options.CacheEnabled, options.CacheTtl); + EntityMcpOptions? mcpOptionsToUse = null; if (options.McpDmlTools is not null || options.McpCustomTool is not null) { - EntityMcpOptions? mcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedure); - if (mcpOptions is null) + mcpOptionsToUse = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedure); + if (mcpOptionsToUse is null) { _logger.LogError("Failed to construct MCP options."); return false; } } - EntityMcpOptions? mcpOptionsToUse = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedure); - // Create new entity. Entity entity = new( Source: source, @@ -1637,23 +1636,20 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, RuntimeConfig // Determine if the entity is or will be a stored procedure bool isStoredProcedureAfterUpdate = doOptionsRepresentStoredProcedure || (isCurrentEntityStoredProcedure && options.SourceType is null); - // Validate MCP options if provided + // Construct and validate MCP options if provided + EntityMcpOptions? updatedMcpOptions = null; if (options.McpDmlTools is not null || options.McpCustomTool is not null) { - EntityMcpOptions? mcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedureAfterUpdate); - - if (mcpOptions is null) + updatedMcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedureAfterUpdate); + if (updatedMcpOptions is null) { _logger.LogError("Failed to construct MCP options."); return false; } } - - EntityMcpOptions? updatedMcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedureAfterUpdate); - - // If MCP options were provided, use them; otherwise keep existing MCP options - if (updatedMcpOptions is null) + else { + // Keep existing MCP options if no updates provided updatedMcpOptions = entity.Mcp; } diff --git a/src/Config/Converters/EntityMcpOptionsConverterFactory.cs b/src/Config/Converters/EntityMcpOptionsConverterFactory.cs index 2c4d4d44df..1cab0b9cc9 100644 --- a/src/Config/Converters/EntityMcpOptionsConverterFactory.cs +++ b/src/Config/Converters/EntityMcpOptionsConverterFactory.cs @@ -70,10 +70,10 @@ private class EntityMcpOptionsConverter : JsonConverter switch (propertyName) { case "custom-tool": - customToolEnabled = reader.TokenType == JsonTokenType.True; + customToolEnabled = reader.GetBoolean(); break; case "dml-tools": - dmlToolsEnabled = reader.TokenType == JsonTokenType.True; + dmlToolsEnabled = reader.GetBoolean(); break; default: throw new JsonException($"Unknown property '{propertyName}' in EntityMcpOptions"); From ac12ece9efe3c3fc3fc0e7db4156e765af9350d6 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Mon, 8 Dec 2025 14:58:21 +0530 Subject: [PATCH 05/15] Added tests for Entity MCP config --- src/Cli.Tests/AddEntityTests.cs | 288 +++++++++++ ...rocedureWithBothMcpProperties.verified.txt | 63 +++ ...eWithBothMcpPropertiesEnabled.verified.txt | 63 +++ ...edureWithMcpCustomToolEnabled.verified.txt | 63 +++ ...EntityWithMcpDmlToolsDisabled.verified.txt | 59 +++ ...eEntityWithMcpDmlToolsEnabled.verified.txt | 59 +++ ...rocedureWithBothMcpProperties.verified.txt | 66 +++ ...eWithBothMcpPropertiesEnabled.verified.txt | 66 +++ ...edureWithMcpCustomToolEnabled.verified.txt | 66 +++ ...EntityWithMcpDmlToolsDisabled.verified.txt | 63 +++ ...eEntityWithMcpDmlToolsEnabled.verified.txt | 63 +++ src/Cli.Tests/UpdateEntityTests.cs | 464 +++++++++++++++++- 12 files changed, 1381 insertions(+), 2 deletions(-) create mode 100644 src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpProperties.verified.txt create mode 100644 src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpPropertiesEnabled.verified.txt create mode 100644 src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt create mode 100644 src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsDisabled.verified.txt create mode 100644 src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsEnabled.verified.txt create mode 100644 src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpProperties.verified.txt create mode 100644 src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpPropertiesEnabled.verified.txt create mode 100644 src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt create mode 100644 src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsDisabled.verified.txt create mode 100644 src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsEnabled.verified.txt diff --git a/src/Cli.Tests/AddEntityTests.cs b/src/Cli.Tests/AddEntityTests.cs index 9386916f7f..f4836a1cca 100644 --- a/src/Cli.Tests/AddEntityTests.cs +++ b/src/Cli.Tests/AddEntityTests.cs @@ -633,5 +633,293 @@ private Task ExecuteVerifyTest(AddOptions options, string config = INITIAL_CONFI return Verify(updatedRuntimeConfig, settings); } + + #region MCP Entity Configuration Tests + + /// + /// Test adding table entity with MCP dml-tools enabled (should serialize as boolean true) + /// + [TestMethod] + public Task AddTableEntityWithMcpDmlToolsEnabled() + { + AddOptions options = new( + source: "books", + permissions: new string[] { "anonymous", "*" }, + entity: "Book", + description: null, + sourceType: "table", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: "true", + mcpCustomTool: null + ); + return ExecuteVerifyTest(options); + } + + /// + /// Test adding table entity with MCP dml-tools disabled (should serialize as boolean false) + /// + [TestMethod] + public Task AddTableEntityWithMcpDmlToolsDisabled() + { + AddOptions options = new( + source: "authors", + permissions: new string[] { "anonymous", "*" }, + entity: "Author", + description: null, + sourceType: "table", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: "false", + mcpCustomTool: null + ); + return ExecuteVerifyTest(options); + } + + /// + /// Test adding stored procedure with MCP custom-tool enabled (should serialize as object) + /// + [TestMethod] + public Task AddStoredProcedureWithMcpCustomToolEnabled() + { + AddOptions options = new( + source: "dbo.GetBookById", + permissions: new string[] { "anonymous", "execute" }, + entity: "GetBookById", + description: null, + sourceType: "stored-procedure", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: null, + mcpCustomTool: "true" + ); + return ExecuteVerifyTest(options); + } + + /// + /// Test adding stored procedure with both MCP properties set to different values (should serialize as object with both) + /// + [TestMethod] + public Task AddStoredProcedureWithBothMcpProperties() + { + AddOptions options = new( + source: "dbo.UpdateBook", + permissions: new string[] { "anonymous", "execute" }, + entity: "UpdateBook", + description: null, + sourceType: "stored-procedure", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: "false", + mcpCustomTool: "true" + ); + return ExecuteVerifyTest(options); + } + + /// + /// Test adding stored procedure with both MCP properties enabled (common use case) + /// + [TestMethod] + public Task AddStoredProcedureWithBothMcpPropertiesEnabled() + { + AddOptions options = new( + source: "dbo.GetAllBooks", + permissions: new string[] { "anonymous", "execute" }, + entity: "GetAllBooks", + description: null, + sourceType: "stored-procedure", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: "true", + mcpCustomTool: "true" + ); + return ExecuteVerifyTest(options); + } + + /// + /// Test that adding table entity with custom-tool fails validation + /// + [TestMethod] + public void AddTableEntityWithInvalidMcpCustomTool() + { + AddOptions options = new( + source: "reviews", + permissions: new string[] { "anonymous", "*" }, + entity: "Review", + description: null, + sourceType: "table", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: null, + mcpCustomTool: "true" + ); + + RuntimeConfigLoader.TryParseConfig(INITIAL_CONFIG, out RuntimeConfig? runtimeConfig); + + Assert.IsFalse(TryAddNewEntity(options, runtimeConfig!, out RuntimeConfig _), + "Should fail to add table entity with custom-tool enabled"); + } + + /// + /// Test that invalid MCP option value fails + /// + [DataTestMethod] + [DataRow("invalid", null, DisplayName = "Invalid dml-tools value")] + [DataRow(null, "invalid", DisplayName = "Invalid custom-tool value")] + [DataRow("yes", "no", DisplayName = "Invalid boolean-like values")] + public void AddEntityWithInvalidMcpOptions(string? mcpDmlTools, string? mcpCustomTool) + { + AddOptions options = new( + source: "MyTable", + permissions: new string[] { "anonymous", "*" }, + entity: "MyEntity", + description: null, + sourceType: "table", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: mcpDmlTools, + mcpCustomTool: mcpCustomTool + ); + + RuntimeConfigLoader.TryParseConfig(INITIAL_CONFIG, out RuntimeConfig? runtimeConfig); + + Assert.IsFalse(TryAddNewEntity(options, runtimeConfig!, out RuntimeConfig _), + "Should fail with invalid MCP option values"); + } + + #endregion MCP Entity Configuration Tests } } diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpProperties.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpProperties.verified.txt new file mode 100644 index 0000000000..38dfae6840 --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpProperties.verified.txt @@ -0,0 +1,63 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [ + { + UpdateBook: { + Source: { + Object: dbo.UpdateBook, + Type: stored-procedure + }, + GraphQL: { + Singular: UpdateBook, + Plural: UpdateBooks, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: false, + UserProvidedCustomToolEnabled: true, + UserProvidedDmlToolsEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpPropertiesEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpPropertiesEnabled.verified.txt new file mode 100644 index 0000000000..f87a181a24 --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpPropertiesEnabled.verified.txt @@ -0,0 +1,63 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [ + { + GetAllBooks: { + Source: { + Object: dbo.GetAllBooks, + Type: stored-procedure + }, + GraphQL: { + Singular: GetAllBooks, + Plural: GetAllBooks, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: true, + UserProvidedCustomToolEnabled: true, + UserProvidedDmlToolsEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt new file mode 100644 index 0000000000..0b81ce23b1 --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt @@ -0,0 +1,63 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [ + { + GetBookById: { + Source: { + Object: dbo.GetBookById, + Type: stored-procedure + }, + GraphQL: { + Singular: GetBookById, + Plural: GetBookByIds, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: true, + UserProvidedCustomToolEnabled: true, + UserProvidedDmlToolsEnabled: false + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsDisabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsDisabled.verified.txt new file mode 100644 index 0000000000..384fdbb80c --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsDisabled.verified.txt @@ -0,0 +1,59 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [ + { + Author: { + Source: { + Object: authors, + Type: Table + }, + GraphQL: { + Singular: Author, + Plural: Authors, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: false, + UserProvidedCustomToolEnabled: false, + UserProvidedDmlToolsEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsEnabled.verified.txt new file mode 100644 index 0000000000..e08eb2e4b3 --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsEnabled.verified.txt @@ -0,0 +1,59 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [ + { + Book: { + Source: { + Object: books, + Type: Table + }, + GraphQL: { + Singular: Book, + Plural: Books, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: true, + UserProvidedCustomToolEnabled: false, + UserProvidedDmlToolsEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpProperties.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpProperties.verified.txt new file mode 100644 index 0000000000..e784664b66 --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpProperties.verified.txt @@ -0,0 +1,66 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + UpdateBook: { + Source: { + Type: stored-procedure + }, + GraphQL: { + Singular: UpdateBook, + Plural: UpdateBooks, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: false, + UserProvidedCustomToolEnabled: true, + UserProvidedDmlToolsEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpPropertiesEnabled.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpPropertiesEnabled.verified.txt new file mode 100644 index 0000000000..7c9fb41700 --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpPropertiesEnabled.verified.txt @@ -0,0 +1,66 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + GetAllBooks: { + Source: { + Type: stored-procedure + }, + GraphQL: { + Singular: GetAllBooks, + Plural: GetAllBooks, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: true, + UserProvidedCustomToolEnabled: true, + UserProvidedDmlToolsEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt new file mode 100644 index 0000000000..ad87c99f15 --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt @@ -0,0 +1,66 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + GetBookById: { + Source: { + Type: stored-procedure + }, + GraphQL: { + Singular: GetBookById, + Plural: GetBookByIds, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: true, + UserProvidedCustomToolEnabled: true, + UserProvidedDmlToolsEnabled: false + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsDisabled.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsDisabled.verified.txt new file mode 100644 index 0000000000..f22dee731f --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsDisabled.verified.txt @@ -0,0 +1,63 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + MyEntity: { + Source: { + Object: MyTable, + Type: Table + }, + GraphQL: { + Singular: MyEntity, + Plural: MyEntities, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: false, + UserProvidedCustomToolEnabled: false, + UserProvidedDmlToolsEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsEnabled.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsEnabled.verified.txt new file mode 100644 index 0000000000..9c3eca020a --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsEnabled.verified.txt @@ -0,0 +1,63 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + MyEntity: { + Source: { + Object: MyTable, + Type: Table + }, + GraphQL: { + Singular: MyEntity, + Plural: MyEntities, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: true, + UserProvidedCustomToolEnabled: false, + UserProvidedDmlToolsEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/UpdateEntityTests.cs b/src/Cli.Tests/UpdateEntityTests.cs index 3a106c0adc..9c6b81536a 100644 --- a/src/Cli.Tests/UpdateEntityTests.cs +++ b/src/Cli.Tests/UpdateEntityTests.cs @@ -1160,7 +1160,9 @@ private static UpdateOptions GenerateBaseUpdateOptions( string? graphQLOperationForStoredProcedure = null, string? cacheEnabled = null, string? cacheTtl = null, - string? description = null + string? description = null, + string? mcpDmlTools = null, + string? mcpCustomTool = null ) { return new( @@ -1197,7 +1199,9 @@ private static UpdateOptions GenerateBaseUpdateOptions( fieldsNameCollection: null, fieldsAliasCollection: null, fieldsDescriptionCollection: null, - fieldsPrimaryKeyCollection: null + fieldsPrimaryKeyCollection: null, + mcpDmlTools: mcpDmlTools, + mcpCustomTool: mcpCustomTool ); } @@ -1211,5 +1215,461 @@ private Task ExecuteVerifyTest(string initialConfig, UpdateOptions options, Veri return Verify(updatedRuntimeConfig, settings); } + + #region MCP Entity Configuration Tests + + /// + /// Test updating table entity with MCP dml-tools enabled + /// + [TestMethod] + public Task TestUpdateTableEntityWithMcpDmlToolsEnabled() + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "MyEntity", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: "true", + mcpCustomTool: null + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""MyEntity"": { + ""source"": ""MyTable"", + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""*""] + } + ] + } + } + }"; + + return ExecuteVerifyTest(initialConfig, options); + } + + /// + /// Test updating table entity with MCP dml-tools disabled + /// + [TestMethod] + public Task TestUpdateTableEntityWithMcpDmlToolsDisabled() + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "MyEntity", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: "false", + mcpCustomTool: null + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""MyEntity"": { + ""source"": ""MyTable"", + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""*""] + } + ] + } + } + }"; + + return ExecuteVerifyTest(initialConfig, options); + } + + /// + /// Test updating stored procedure with MCP custom-tool enabled + /// + [TestMethod] + public Task TestUpdateStoredProcedureWithMcpCustomToolEnabled() + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "GetBookById", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: null, + mcpCustomTool: "true" + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""GetBookById"": { + ""source"": ""dbo.GetBookById"", + ""source"": { + ""type"": ""stored-procedure"" + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""execute""] + } + ] + } + } + }"; + + return ExecuteVerifyTest(initialConfig, options); + } + + /// + /// Test updating stored procedure with both MCP properties + /// + [TestMethod] + public Task TestUpdateStoredProcedureWithBothMcpProperties() + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "UpdateBook", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: "false", + mcpCustomTool: "true" + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""UpdateBook"": { + ""source"": ""dbo.UpdateBook"", + ""source"": { + ""type"": ""stored-procedure"" + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""execute""] + } + ] + } + } + }"; + + return ExecuteVerifyTest(initialConfig, options); + } + + /// + /// Test updating stored procedure with both MCP properties enabled + /// + [TestMethod] + public Task TestUpdateStoredProcedureWithBothMcpPropertiesEnabled() + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "GetAllBooks", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: "true", + mcpCustomTool: "true" + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""GetAllBooks"": { + ""source"": ""dbo.GetAllBooks"", + ""source"": { + ""type"": ""stored-procedure"" + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""execute""] + } + ] + } + } + }"; + + return ExecuteVerifyTest(initialConfig, options); + } + + /// + /// Test that updating table entity with custom-tool fails validation + /// + [TestMethod] + public void TestUpdateTableEntityWithInvalidMcpCustomTool() + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "MyEntity", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: null, + mcpCustomTool: "true" + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""MyEntity"": { + ""source"": ""MyTable"", + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""*""] + } + ] + } + } + }"; + + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(initialConfig, out RuntimeConfig? runtimeConfig)); + + Assert.IsFalse(TryUpdateExistingEntity(options, runtimeConfig!, out RuntimeConfig _), + "Should fail to update table entity with custom-tool enabled"); + } + + /// + /// Test that invalid MCP option value fails + /// + [DataTestMethod] + [DataRow("invalid", null, DisplayName = "Invalid dml-tools value")] + [DataRow(null, "invalid", DisplayName = "Invalid custom-tool value")] + [DataRow("yes", "no", DisplayName = "Invalid boolean-like values")] + public void TestUpdateEntityWithInvalidMcpOptions(string? mcpDmlTools, string? mcpCustomTool) + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "MyEntity", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: mcpDmlTools, + mcpCustomTool: mcpCustomTool + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""MyEntity"": { + ""source"": ""MyTable"", + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""*""] + } + ] + } + } + }"; + + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(initialConfig, out RuntimeConfig? runtimeConfig)); + + Assert.IsFalse(TryUpdateExistingEntity(options, runtimeConfig!, out RuntimeConfig _), + $"Should fail to update entity with invalid MCP options: dml-tools={mcpDmlTools}, custom-tool={mcpCustomTool}"); + } + + #endregion MCP Entity Configuration Tests } } From f30ef6fd9c327b276879cc7e65f84a73ed281619 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Mon, 8 Dec 2025 15:50:29 +0530 Subject: [PATCH 06/15] Added additional tests and fixes --- ...edureWithMcpCustomToolEnabled.verified.txt | 2 +- ...edureWithMcpCustomToolEnabled.verified.txt | 2 +- src/Config/ObjectModel/EntityMcpOptions.cs | 3 +- .../EntityMcpConfigurationTests.cs | 504 ++++++++++++++++++ 4 files changed, 508 insertions(+), 3 deletions(-) create mode 100644 src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt index 0b81ce23b1..81628df772 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt @@ -53,7 +53,7 @@ ], Mcp: { CustomToolEnabled: true, - DmlToolEnabled: true, + DmlToolEnabled: false, UserProvidedCustomToolEnabled: true, UserProvidedDmlToolsEnabled: false } diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt index ad87c99f15..7461ea4ae5 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt @@ -56,7 +56,7 @@ ], Mcp: { CustomToolEnabled: true, - DmlToolEnabled: true, + DmlToolEnabled: false, UserProvidedCustomToolEnabled: true, UserProvidedDmlToolsEnabled: false } diff --git a/src/Config/ObjectModel/EntityMcpOptions.cs b/src/Config/ObjectModel/EntityMcpOptions.cs index b72d1d3a09..5485926e39 100644 --- a/src/Config/ObjectModel/EntityMcpOptions.cs +++ b/src/Config/ObjectModel/EntityMcpOptions.cs @@ -19,9 +19,10 @@ public record EntityMcpOptions /// /// Indicates whether DML tools are enabled for this entity. + /// Defaults to false when not explicitly provided. /// [JsonPropertyName("dml-tools")] - public bool DmlToolEnabled { get; init; } = true; + public bool DmlToolEnabled { get; init; } = false; /// /// Flag which informs CLI and JSON serializer whether to write the CustomToolEnabled diff --git a/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs b/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs new file mode 100644 index 0000000000..ea6f5f9e34 --- /dev/null +++ b/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs @@ -0,0 +1,504 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Config.ObjectModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.Configuration +{ + /// + /// Tests for entity-level MCP configuration deserialization and validation. + /// Validates that EntityMcpOptions are correctly deserialized from runtime config JSON. + /// + [TestClass] + public class EntityMcpConfigurationTests + { + /// + /// Test that deserializing boolean 'true' shorthand correctly sets dml-tools enabled. + /// + [TestMethod] + public void DeserializeConfig_McpBooleanTrue_EnablesDmlToolsOnly() + { + // Arrange + string config = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"" }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, + ""host"": { ""mode"": ""development"" } + }, + ""entities"": { + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": true + } + } + }"; + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); + + Entity bookEntity = runtimeConfig.Entities["Book"]; + Assert.IsNotNull(bookEntity.Mcp, "MCP options should be present"); + Assert.IsTrue(bookEntity.Mcp.DmlToolEnabled, "DmlTools should be enabled"); + Assert.IsFalse(bookEntity.Mcp.CustomToolEnabled, "CustomTool should be disabled (default)"); + } + + /// + /// Test that deserializing boolean 'false' shorthand correctly sets dml-tools disabled. + /// + [TestMethod] + public void DeserializeConfig_McpBooleanFalse_DisablesDmlTools() + { + // Arrange + string config = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"" }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, + ""host"": { ""mode"": ""development"" } + }, + ""entities"": { + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": false + } + } + }"; + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); + + Entity bookEntity = runtimeConfig.Entities["Book"]; + Assert.IsNotNull(bookEntity.Mcp, "MCP options should be present"); + Assert.IsFalse(bookEntity.Mcp.DmlToolEnabled, "DmlTools should be disabled"); + Assert.IsFalse(bookEntity.Mcp.CustomToolEnabled, "CustomTool should be disabled (default)"); + } + + /// + /// Test that deserializing object format with both properties works correctly. + /// + [TestMethod] + public void DeserializeConfig_McpObject_SetsBothProperties() + { + // Arrange + string config = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"" }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, + ""host"": { ""mode"": ""development"" } + }, + ""entities"": { + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": true, + ""dml-tools"": false + } + } + } + }"; + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + + Entity spEntity = runtimeConfig.Entities["GetBook"]; + Assert.IsNotNull(spEntity.Mcp, "MCP options should be present"); + Assert.IsTrue(spEntity.Mcp.CustomToolEnabled, "CustomTool should be enabled"); + Assert.IsFalse(spEntity.Mcp.DmlToolEnabled, "DmlTools should be disabled"); + } + + /// + /// Test that deserializing object format with only dml-tools works. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithDmlToolsOnly_WorksCorrectly() + { + // Arrange + string config = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"" }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, + ""host"": { ""mode"": ""development"" } + }, + ""entities"": { + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": { + ""dml-tools"": true + } + } + } + }"; + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); + + Entity bookEntity = runtimeConfig.Entities["Book"]; + Assert.IsNotNull(bookEntity.Mcp, "MCP options should be present"); + Assert.IsTrue(bookEntity.Mcp.DmlToolEnabled, "DmlTools should be enabled"); + Assert.IsFalse(bookEntity.Mcp.CustomToolEnabled, "CustomTool should be disabled (default)"); + } + + /// + /// Test that entity without MCP configuration has null MCP options. + /// + [TestMethod] + public void DeserializeConfig_NoMcp_HasNullMcpOptions() + { + // Arrange + string config = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"" }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, + ""host"": { ""mode"": ""development"" } + }, + ""entities"": { + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }] + } + } + }"; + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); + + Entity bookEntity = runtimeConfig.Entities["Book"]; + Assert.IsNull(bookEntity.Mcp, "MCP options should be null when not specified"); + } + + /// + /// Test that deserializing object format with both properties set to true works correctly. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithBothTrue_SetsCorrectly() + { + // Arrange + string config = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"" }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, + ""host"": { ""mode"": ""development"" } + }, + ""entities"": { + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": true, + ""dml-tools"": true + } + } + } + }"; + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + + Entity spEntity = runtimeConfig.Entities["GetBook"]; + Assert.IsNotNull(spEntity.Mcp, "MCP options should be present"); + Assert.IsTrue(spEntity.Mcp.CustomToolEnabled, "CustomTool should be enabled"); + Assert.IsTrue(spEntity.Mcp.DmlToolEnabled, "DmlTools should be enabled"); + } + + /// + /// Test that deserializing object format with both properties set to false works correctly. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithBothFalse_SetsCorrectly() + { + // Arrange + string config = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"" }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, + ""host"": { ""mode"": ""development"" } + }, + ""entities"": { + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": false, + ""dml-tools"": false + } + } + } + }"; + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + + Entity spEntity = runtimeConfig.Entities["GetBook"]; + Assert.IsNotNull(spEntity.Mcp, "MCP options should be present"); + Assert.IsFalse(spEntity.Mcp.CustomToolEnabled, "CustomTool should be disabled"); + Assert.IsFalse(spEntity.Mcp.DmlToolEnabled, "DmlTools should be disabled"); + } + + /// + /// Test that deserializing object format with only custom-tool works. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithCustomToolOnly_WorksCorrectly() + { + // Arrange + string config = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"" }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, + ""host"": { ""mode"": ""development"" } + }, + ""entities"": { + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": true + } + } + } + }"; + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + + Entity spEntity = runtimeConfig.Entities["GetBook"]; + Assert.IsNotNull(spEntity.Mcp, "MCP options should be present"); + Assert.IsTrue(spEntity.Mcp.CustomToolEnabled, "CustomTool should be enabled"); + Assert.IsFalse(spEntity.Mcp.DmlToolEnabled, "DmlTools should be disabled (default is false)"); + } + + /// + /// Test that deserializing config with multiple entities having different MCP settings works. + /// + [TestMethod] + public void DeserializeConfig_MultipleEntitiesWithDifferentMcpSettings_WorksCorrectly() + { + // Arrange + string config = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"" }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, + ""host"": { ""mode"": ""development"" } + }, + ""entities"": { + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": true + }, + ""Author"": { + ""source"": ""authors"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": false + }, + ""Publisher"": { + ""source"": ""publishers"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }] + }, + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": true, + ""dml-tools"": false + } + } + } + }"; + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + + // Book: mcp = true + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); + Entity bookEntity = runtimeConfig.Entities["Book"]; + Assert.IsNotNull(bookEntity.Mcp); + Assert.IsTrue(bookEntity.Mcp.DmlToolEnabled); + Assert.IsFalse(bookEntity.Mcp.CustomToolEnabled); + + // Author: mcp = false + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Author")); + Entity authorEntity = runtimeConfig.Entities["Author"]; + Assert.IsNotNull(authorEntity.Mcp); + Assert.IsFalse(authorEntity.Mcp.DmlToolEnabled); + Assert.IsFalse(authorEntity.Mcp.CustomToolEnabled); + + // Publisher: no mcp + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Publisher")); + Entity publisherEntity = runtimeConfig.Entities["Publisher"]; + Assert.IsNull(publisherEntity.Mcp); + + // GetBook: mcp object + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + Entity spEntity = runtimeConfig.Entities["GetBook"]; + Assert.IsNotNull(spEntity.Mcp); + Assert.IsTrue(spEntity.Mcp.CustomToolEnabled); + Assert.IsFalse(spEntity.Mcp.DmlToolEnabled); + } + + /// + /// Test that deserializing invalid MCP value (non-boolean, non-object) fails gracefully. + /// + [TestMethod] + public void DeserializeConfig_InvalidMcpValue_FailsGracefully() + { + // Arrange + string config = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"" }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, + ""host"": { ""mode"": ""development"" } + }, + ""entities"": { + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": ""invalid"" + } + } + }"; + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsFalse(success, "Config parsing should fail with invalid MCP value"); + } + + /// + /// Test that deserializing MCP object with unknown property fails gracefully. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithUnknownProperty_FailsGracefully() + { + // Arrange + string config = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"" }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, + ""host"": { ""mode"": ""development"" } + }, + ""entities"": { + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": { + ""dml-tools"": true, + ""unknown-property"": true + } + } + } + }"; + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsFalse(success, "Config parsing should fail with unknown MCP property"); + } + } +} From f0329637f5d489415472c0278449bc8c9105a4d0 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Mon, 8 Dec 2025 16:23:44 +0530 Subject: [PATCH 07/15] Fix formattings --- .../EntityMcpConfigurationTests.cs | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs b/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs index ea6f5f9e34..cf0edc4ece 100644 --- a/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs +++ b/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs @@ -48,7 +48,7 @@ public void DeserializeConfig_McpBooleanTrue_EnablesDmlToolsOnly() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); - + Entity bookEntity = runtimeConfig.Entities["Book"]; Assert.IsNotNull(bookEntity.Mcp, "MCP options should be present"); Assert.IsTrue(bookEntity.Mcp.DmlToolEnabled, "DmlTools should be enabled"); @@ -89,7 +89,7 @@ public void DeserializeConfig_McpBooleanFalse_DisablesDmlTools() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); - + Entity bookEntity = runtimeConfig.Entities["Book"]; Assert.IsNotNull(bookEntity.Mcp, "MCP options should be present"); Assert.IsFalse(bookEntity.Mcp.DmlToolEnabled, "DmlTools should be disabled"); @@ -133,7 +133,7 @@ public void DeserializeConfig_McpObject_SetsBothProperties() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); - + Entity spEntity = runtimeConfig.Entities["GetBook"]; Assert.IsNotNull(spEntity.Mcp, "MCP options should be present"); Assert.IsTrue(spEntity.Mcp.CustomToolEnabled, "CustomTool should be enabled"); @@ -176,7 +176,7 @@ public void DeserializeConfig_McpObjectWithDmlToolsOnly_WorksCorrectly() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); - + Entity bookEntity = runtimeConfig.Entities["Book"]; Assert.IsNotNull(bookEntity.Mcp, "MCP options should be present"); Assert.IsTrue(bookEntity.Mcp.DmlToolEnabled, "DmlTools should be enabled"); @@ -216,7 +216,7 @@ public void DeserializeConfig_NoMcp_HasNullMcpOptions() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); - + Entity bookEntity = runtimeConfig.Entities["Book"]; Assert.IsNull(bookEntity.Mcp, "MCP options should be null when not specified"); } @@ -258,7 +258,7 @@ public void DeserializeConfig_McpObjectWithBothTrue_SetsCorrectly() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); - + Entity spEntity = runtimeConfig.Entities["GetBook"]; Assert.IsNotNull(spEntity.Mcp, "MCP options should be present"); Assert.IsTrue(spEntity.Mcp.CustomToolEnabled, "CustomTool should be enabled"); @@ -302,7 +302,7 @@ public void DeserializeConfig_McpObjectWithBothFalse_SetsCorrectly() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); - + Entity spEntity = runtimeConfig.Entities["GetBook"]; Assert.IsNotNull(spEntity.Mcp, "MCP options should be present"); Assert.IsFalse(spEntity.Mcp.CustomToolEnabled, "CustomTool should be disabled"); @@ -345,7 +345,7 @@ public void DeserializeConfig_McpObjectWithCustomToolOnly_WorksCorrectly() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); - + Entity spEntity = runtimeConfig.Entities["GetBook"]; Assert.IsNotNull(spEntity.Mcp, "MCP options should be present"); Assert.IsTrue(spEntity.Mcp.CustomToolEnabled, "CustomTool should be enabled"); @@ -402,26 +402,26 @@ public void DeserializeConfig_MultipleEntitiesWithDifferentMcpSettings_WorksCorr // Assert Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); - + // Book: mcp = true Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); Entity bookEntity = runtimeConfig.Entities["Book"]; Assert.IsNotNull(bookEntity.Mcp); Assert.IsTrue(bookEntity.Mcp.DmlToolEnabled); Assert.IsFalse(bookEntity.Mcp.CustomToolEnabled); - + // Author: mcp = false Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Author")); Entity authorEntity = runtimeConfig.Entities["Author"]; Assert.IsNotNull(authorEntity.Mcp); Assert.IsFalse(authorEntity.Mcp.DmlToolEnabled); Assert.IsFalse(authorEntity.Mcp.CustomToolEnabled); - + // Publisher: no mcp Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Publisher")); Entity publisherEntity = runtimeConfig.Entities["Publisher"]; Assert.IsNull(publisherEntity.Mcp); - + // GetBook: mcp object Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); Entity spEntity = runtimeConfig.Entities["GetBook"]; @@ -458,7 +458,7 @@ public void DeserializeConfig_InvalidMcpValue_FailsGracefully() }"; // Act - bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + bool success = RuntimeConfigLoader.TryParseConfig(config, out _); // Assert Assert.IsFalse(success, "Config parsing should fail with invalid MCP value"); @@ -495,7 +495,7 @@ public void DeserializeConfig_McpObjectWithUnknownProperty_FailsGracefully() }"; // Act - bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + bool success = RuntimeConfigLoader.TryParseConfig(config, out _); // Assert Assert.IsFalse(success, "Config parsing should fail with unknown MCP property"); From 78d76d6e4fda46de2c4068d739e8e9943328b66e Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 11 Dec 2025 09:19:49 +0530 Subject: [PATCH 08/15] POC for custom tool --- .../BuiltInTools/DynamicCustomTool.cs | 365 ++++++++++++++++++ .../Core/CustomMcpToolFactory.cs | 66 ++++ .../Core/McpServiceCollectionExtensions.cs | 19 +- src/Service.Tests/dab-config.MsSql.json | 24 +- 4 files changed, 467 insertions(+), 7 deletions(-) create mode 100644 src/Azure.DataApiBuilder.Mcp/BuiltInTools/DynamicCustomTool.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DynamicCustomTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DynamicCustomTool.cs new file mode 100644 index 0000000000..4c24209cc2 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DynamicCustomTool.cs @@ -0,0 +1,365 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Data.Common; +using System.Text.Json; +using System.Text.RegularExpressions; +using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Config.DatabasePrimitives; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Core.Models; +using Azure.DataApiBuilder.Core.Resolvers; +using Azure.DataApiBuilder.Core.Resolvers.Factories; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Mcp.Model; +using Azure.DataApiBuilder.Mcp.Utils; +using Azure.DataApiBuilder.Service.Exceptions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using static Azure.DataApiBuilder.Mcp.Model.McpEnums; + +namespace Azure.DataApiBuilder.Mcp.BuiltInTools +{ + /// + /// Dynamic custom MCP tool generated from stored procedure entity configuration. + /// Each custom tool represents a single stored procedure exposed as a dedicated MCP tool. + /// + public class DynamicCustomTool : IMcpTool + { + private readonly string _entityName; + private readonly Entity _entity; + + /// + /// Initializes a new instance of DynamicCustomTool. + /// + /// The entity name from configuration. + /// The entity configuration object. + public DynamicCustomTool(string entityName, Entity entity) + { + _entityName = entityName ?? throw new ArgumentNullException(nameof(entityName)); + _entity = entity ?? throw new ArgumentNullException(nameof(entity)); + + // Validate that this is a stored procedure + if (_entity.Source.Type != EntitySourceType.StoredProcedure) + { + throw new ArgumentException( + $"Custom tools can only be created for stored procedures. Entity '{entityName}' is of type '{_entity.Source.Type}'.", + nameof(entity)); + } + } + + /// + /// Gets the type of the tool, which is Custom for dynamically generated tools. + /// + public ToolType ToolType { get; } = ToolType.Custom; + + /// + /// Gets the metadata for this custom tool, including name, description, and input schema. + /// + public Tool GetToolMetadata() + { + string toolName = ConvertToToolName(_entityName); + string description = _entity.Description ?? $"Execute {_entityName} stored procedure"; + + // Build input schema based on parameters + JsonElement inputSchema = BuildInputSchema(); + + return new Tool + { + Name = toolName, + Description = description, + InputSchema = inputSchema + }; + } + + /// + /// Executes the stored procedure represented by this custom tool. + /// + public async Task ExecuteAsync( + JsonDocument? arguments, + IServiceProvider serviceProvider, + CancellationToken cancellationToken = default) + { + ILogger? logger = serviceProvider.GetService>(); + string toolName = GetToolMetadata().Name; + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + // 1) Resolve required services & configuration + RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); + RuntimeConfig config = runtimeConfigProvider.GetConfig(); + + // 2) Parse arguments - for POC, accept simple object format + Dictionary parameters = new(); + if (arguments != null) + { + foreach (JsonProperty property in arguments.RootElement.EnumerateObject()) + { + parameters[property.Name] = GetParameterValue(property.Value); + } + } + + // 3) Validate entity still exists in configuration + if (!config.Entities.TryGetValue(_entityName, out Entity? entityConfig)) + { + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{_entityName}' not found in configuration.", logger); + } + + if (entityConfig.Source.Type != EntitySourceType.StoredProcedure) + { + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity {_entityName} is not a stored procedure.", logger); + } + + // 4) Resolve metadata + if (!McpMetadataHelper.TryResolveMetadata( + _entityName, + config, + serviceProvider, + out ISqlMetadataProvider sqlMetadataProvider, + out DatabaseObject dbObject, + out string dataSourceName, + out string metadataError)) + { + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); + } + + // 5) Authorization check + IAuthorizationResolver authResolver = serviceProvider.GetRequiredService(); + IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService(); + HttpContext? httpContext = httpContextAccessor.HttpContext; + + if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleError)) + { + return McpErrorHelpers.PermissionDenied(toolName, _entityName, "execute", roleError, logger); + } + + if (!McpAuthorizationHelper.TryResolveAuthorizedRole( + httpContext!, + authResolver, + _entityName, + EntityActionOperation.Execute, + out string? effectiveRole, + out string authError)) + { + return McpErrorHelpers.PermissionDenied(toolName, _entityName, "execute", authError, logger); + } + + // 6) Build request payload + JsonElement? requestPayloadRoot = null; + if (parameters.Count > 0) + { + string jsonPayload = JsonSerializer.Serialize(parameters); + using JsonDocument doc = JsonDocument.Parse(jsonPayload); + requestPayloadRoot = doc.RootElement.Clone(); + } + + // 7) Build stored procedure execution context + StoredProcedureRequestContext context = new( + entityName: _entityName, + dbo: dbObject, + requestPayloadRoot: requestPayloadRoot, + operationType: EntityActionOperation.Execute); + + // Add user-provided parameters + if (requestPayloadRoot != null) + { + foreach (JsonProperty property in requestPayloadRoot.Value.EnumerateObject()) + { + context.FieldValuePairsInBody[property.Name] = GetParameterValue(property.Value); + } + } + + // Add default parameters from configuration if not provided + if (entityConfig.Source.Parameters != null) + { + foreach (ParameterMetadata param in entityConfig.Source.Parameters) + { + if (!context.FieldValuePairsInBody.ContainsKey(param.Name)) + { + context.FieldValuePairsInBody[param.Name] = param.Default; + } + } + } + + // Populate resolved parameters + context.PopulateResolvedParameters(); + + // 8) Execute stored procedure + DatabaseType dbType = config.GetDataSourceFromDataSourceName(dataSourceName).DatabaseType; + IQueryEngineFactory queryEngineFactory = serviceProvider.GetRequiredService(); + IQueryEngine queryEngine = queryEngineFactory.GetQueryEngine(dbType); + + IActionResult? queryResult = null; + + try + { + cancellationToken.ThrowIfCancellationRequested(); + queryResult = await queryEngine.ExecuteAsync(context, dataSourceName).ConfigureAwait(false); + } + catch (DataApiBuilderException dabEx) + { + logger?.LogError(dabEx, "Error executing custom tool {ToolName} for entity {Entity}", toolName, _entityName); + return McpResponseBuilder.BuildErrorResult(toolName, "ExecutionError", dabEx.Message, logger); + } + catch (SqlException sqlEx) + { + logger?.LogError(sqlEx, "SQL error executing custom tool {ToolName}", toolName); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", $"Database error: {sqlEx.Message}", logger); + } + catch (DbException dbEx) + { + logger?.LogError(dbEx, "Database error executing custom tool {ToolName}", toolName); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", $"Database error: {dbEx.Message}", logger); + } + catch (Exception ex) + { + logger?.LogError(ex, "Unexpected error executing custom tool {ToolName}", toolName); + return McpResponseBuilder.BuildErrorResult(toolName, "UnexpectedError", "An error occurred during execution.", logger); + } + + // 9) Build success response + return BuildExecuteSuccessResponse(toolName, _entityName, parameters, queryResult, logger); + } + catch (OperationCanceledException) + { + return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", "The operation was canceled.", logger); + } + catch (Exception ex) + { + logger?.LogError(ex, "Unexpected error in DynamicCustomTool for {EntityName}", _entityName); + return McpResponseBuilder.BuildErrorResult(toolName, "UnexpectedError", "An unexpected error occurred.", logger); + } + } + + /// + /// Converts entity name to tool name format (lowercase with underscores). + /// + private static string ConvertToToolName(string entityName) + { + // Convert PascalCase to snake_case + string result = Regex.Replace(entityName, "([a-z0-9])([A-Z])", "$1_$2"); + return result.ToLowerInvariant(); + } + + /// + /// Builds the input schema for the tool based on entity parameters. + /// + private JsonElement BuildInputSchema() + { + var schema = new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary() + }; + + if (_entity.Source.Parameters != null && _entity.Source.Parameters.Any()) + { + var properties = (Dictionary)schema["properties"]; + + foreach (var param in _entity.Source.Parameters) + { + properties[param.Name] = new Dictionary + { + ["type"] = "string", + ["description"] = param.Description ?? $"Parameter {param.Name}" + }; + } + } + else + { + schema["properties"] = new Dictionary(); + } + + return JsonSerializer.SerializeToElement(schema); + } + + /// + /// Converts a JSON element to its appropriate CLR type. + /// + private static object? GetParameterValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => + element.TryGetInt64(out long longValue) ? longValue : + element.TryGetDecimal(out decimal decimalValue) ? decimalValue : + element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.ToString() + }; + } + + /// + /// Builds a successful response for the execute operation. + /// + private static CallToolResult BuildExecuteSuccessResponse( + string toolName, + string entityName, + Dictionary? parameters, + IActionResult? queryResult, + ILogger? logger) + { + Dictionary responseData = new() + { + ["entity"] = entityName, + ["message"] = "Execution successful" + }; + + if (parameters?.Count > 0) + { + responseData["parameters"] = parameters; + } + + // Handle different result types + if (queryResult is OkObjectResult okResult && okResult.Value != null) + { + if (okResult.Value is JsonDocument jsonDoc) + { + JsonElement root = jsonDoc.RootElement; + responseData["value"] = root.ValueKind == JsonValueKind.Array ? root : JsonSerializer.SerializeToElement(new[] { root }); + } + else if (okResult.Value is JsonElement jsonElement) + { + responseData["value"] = jsonElement.ValueKind == JsonValueKind.Array ? jsonElement : JsonSerializer.SerializeToElement(new[] { jsonElement }); + } + else + { + JsonElement serialized = JsonSerializer.SerializeToElement(okResult.Value); + responseData["value"] = serialized; + } + } + else if (queryResult is BadRequestObjectResult badRequest) + { + return McpResponseBuilder.BuildErrorResult( + toolName, + "BadRequest", + badRequest.Value?.ToString() ?? "Bad request", + logger); + } + else if (queryResult is UnauthorizedObjectResult) + { + return McpErrorHelpers.PermissionDenied(toolName, entityName, "execute", "Unauthorized", logger); + } + else + { + responseData["value"] = JsonSerializer.SerializeToElement(Array.Empty()); + } + + return McpResponseBuilder.BuildSuccessResult( + responseData, + logger, + $"Custom tool {toolName} executed successfully for entity {entityName}." + ); + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs b/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs new file mode 100644 index 0000000000..569155a796 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Azure.DataApiBuilder.Mcp.Model; +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Mcp.Core +{ + /// + /// Factory for creating custom MCP tools from stored procedure entity configurations. + /// Scans runtime configuration and generates dynamic tools for entities marked with custom-tool enabled. + /// + public class CustomMcpToolFactory + { + /// + /// Creates custom MCP tools from entities configured with "mcp": { "custom-tool": true }. + /// + /// The runtime configuration containing entity definitions. + /// Optional logger for diagnostic information. + /// Enumerable of custom tools generated from configuration. + public static IEnumerable CreateCustomTools(RuntimeConfig config, ILogger? logger = null) + { + if (config?.Entities == null) + { + logger?.LogWarning("No entities found in runtime configuration for custom tool generation."); + return Enumerable.Empty(); + } + + List customTools = new(); + int customToolCount = 0; + + foreach ((string entityName, Entity entity) in config.Entities) + { + // Filter: Only stored procedures with custom-tool enabled + if (entity.Source.Type == EntitySourceType.StoredProcedure && + entity.Mcp?.CustomToolEnabled == true) + { + try + { + DynamicCustomTool tool = new(entityName, entity); + customToolCount++; + + logger?.LogInformation( + "Created custom MCP tool '{ToolName}' for stored procedure entity '{EntityName}'", + tool.GetToolMetadata().Name, + entityName); + + customTools.Add(tool); + } + catch (Exception ex) + { + logger?.LogError( + ex, + "Failed to create custom tool for entity '{EntityName}'. Skipping.", + entityName); + } + } + } + + logger?.LogInformation("Custom MCP tool generation complete. Created {Count} custom tools.", customToolCount); + return customTools; + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs index 01f6015786..c1e7045a5f 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using System.Reflection; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Mcp.BuiltInTools; using Azure.DataApiBuilder.Mcp.Model; using Microsoft.Extensions.DependencyInjection; @@ -38,6 +39,9 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service // Auto-discover and register all MCP tools RegisterAllMcpTools(services); + // Register custom tools from configuration + RegisterCustomTools(services, runtimeConfig); + // Configure MCP server services.ConfigureMcpServer(); @@ -54,12 +58,25 @@ private static void RegisterAllMcpTools(IServiceCollection services) IEnumerable toolTypes = mcpAssembly.GetTypes() .Where(t => t.IsClass && !t.IsAbstract && - typeof(IMcpTool).IsAssignableFrom(t)); + typeof(IMcpTool).IsAssignableFrom(t) && + t != typeof(DynamicCustomTool)); // Exclude DynamicCustomTool from auto-registration foreach (Type toolType in toolTypes) { services.AddSingleton(typeof(IMcpTool), toolType); } } + + /// + /// Registers custom MCP tools generated from stored procedure entity configurations. + /// + private static void RegisterCustomTools(IServiceCollection services, RuntimeConfig config) + { + // Create custom tools and register each as a singleton + foreach (IMcpTool customTool in CustomMcpToolFactory.CreateCustomTools(config)) + { + services.AddSingleton(customTool); + } + } } } diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index d5e903d4f3..798a343d47 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -1,8 +1,8 @@ -{ +{ "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json", "data-source": { "database-type": "mssql", - "connection-string": "Server=tcp:127.0.0.1,1433;Persist Security Info=False;User ID=sa;Password=REPLACEME;MultipleActiveResultSets=False;Connection Timeout=5;", + "connection-string": "Server=tcp:localhost,1433;Persist Security Info=False;Initial Catalog=Library;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=30;", "options": { "set-session-context": true } @@ -2890,7 +2890,10 @@ } ] } - ] + ], + "mcp": { + "custom-tool": true + } }, "GetBook": { "source": { @@ -2928,7 +2931,10 @@ } ] } - ] + ], + "mcp": { + "custom-tool": true + } }, "GetPublisher": { "source": { @@ -3008,7 +3014,10 @@ } ] } - ] + ], + "mcp": { + "custom-tool": true + } }, "CountBooks": { "source": { @@ -3046,7 +3055,10 @@ } ] } - ] + ], + "mcp": { + "custom-tool": true + } }, "DeleteLastInsertedBook": { "source": { From b56e7486a81fdb6326a5474816f7258a4d4feb6f Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 18 Dec 2025 19:44:45 +0530 Subject: [PATCH 09/15] Merge latest changes from main branch - MCP dml-tools support and related updates --- schemas/dab.draft.schema.json | 4 +- src/Cli.Tests/AddEntityTests.cs | 56 +-- src/Cli.Tests/ModuleInitializer.cs | 4 + ...rocedureWithBothMcpProperties.verified.txt | 4 +- ...eWithBothMcpPropertiesEnabled.verified.txt | 4 +- ...edureWithMcpCustomToolEnabled.verified.txt | 4 +- ...DmlTools=false_source=authors.verified.txt | 57 +++ ...mcpDmlTools=true_source=books.verified.txt | 57 +++ ...rocedureWithBothMcpProperties.verified.txt | 4 +- ...eWithBothMcpPropertiesEnabled.verified.txt | 4 +- ...edureWithMcpCustomToolEnabled.verified.txt | 4 +- ...DmlTools_newMcpDmlTools=false.verified.txt | 61 +++ ...pDmlTools_newMcpDmlTools=true.verified.txt | 61 +++ src/Cli.Tests/UpdateEntityTests.cs | 102 ++--- src/Cli/Commands/EntityOptions.cs | 4 +- src/Cli/ConfigGenerator.cs | 9 +- .../EntityMcpOptionsConverterFactory.cs | 4 +- ...erializationVariableReplacementSettings.cs | 2 +- src/Config/ObjectModel/EntityMcpOptions.cs | 4 +- src/Config/RuntimeConfigLoader.cs | 1 + .../EntityMcpConfigurationTests.cs | 371 ++++++------------ src/Service.Tests/ModuleInitializer.cs | 4 + 22 files changed, 433 insertions(+), 392 deletions(-) create mode 100644 src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=false_source=authors.verified.txt create mode 100644 src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=true_source=books.verified.txt create mode 100644 src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=false.verified.txt create mode 100644 src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=true.verified.txt diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 01e3a874f0..61ab7474b7 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -966,7 +966,7 @@ "oneOf": [ { "type": "boolean", - "description": "Boolean shorthand: true enables dml-tools, false disables dml-tools." + "description": "Boolean shorthand: true enables dml-tools only (custom-tool remains false), false disables all MCP functionality." }, { "type": "object", @@ -1195,7 +1195,7 @@ "required": ["type"] } }, - "errorMessage": "custom-tool can only be enabled for entities with source type 'stored-procedure'" + "errorMessage": "custom-tool can only be enabled for entities with source type 'stored-procedure'." } } ] diff --git a/src/Cli.Tests/AddEntityTests.cs b/src/Cli.Tests/AddEntityTests.cs index f4836a1cca..e96d131880 100644 --- a/src/Cli.Tests/AddEntityTests.cs +++ b/src/Cli.Tests/AddEntityTests.cs @@ -637,15 +637,17 @@ private Task ExecuteVerifyTest(AddOptions options, string config = INITIAL_CONFI #region MCP Entity Configuration Tests /// - /// Test adding table entity with MCP dml-tools enabled (should serialize as boolean true) + /// Test adding table entity with MCP dml-tools enabled or disabled /// - [TestMethod] - public Task AddTableEntityWithMcpDmlToolsEnabled() + [DataTestMethod] + [DataRow("true", "books", "Book", DisplayName = "AddTableEntityWithMcpDmlToolsEnabled")] + [DataRow("false", "authors", "Author", DisplayName = "AddTableEntityWithMcpDmlToolsDisabled")] + public Task AddTableEntityWithMcpDmlTools(string mcpDmlTools, string source, string entity) { AddOptions options = new( - source: "books", + source: source, permissions: new string[] { "anonymous", "*" }, - entity: "Book", + entity: entity, description: null, sourceType: "table", sourceParameters: null, @@ -669,49 +671,13 @@ public Task AddTableEntityWithMcpDmlToolsEnabled() fieldsAliasCollection: [], fieldsDescriptionCollection: [], fieldsPrimaryKeyCollection: [], - mcpDmlTools: "true", + mcpDmlTools: mcpDmlTools, mcpCustomTool: null ); - return ExecuteVerifyTest(options); - } - /// - /// Test adding table entity with MCP dml-tools disabled (should serialize as boolean false) - /// - [TestMethod] - public Task AddTableEntityWithMcpDmlToolsDisabled() - { - AddOptions options = new( - source: "authors", - permissions: new string[] { "anonymous", "*" }, - entity: "Author", - description: null, - sourceType: "table", - sourceParameters: null, - sourceKeyFields: null, - restRoute: null, - graphQLType: null, - fieldsToInclude: Array.Empty(), - fieldsToExclude: Array.Empty(), - policyRequest: null, - policyDatabase: null, - cacheEnabled: null, - cacheTtl: null, - config: TEST_RUNTIME_CONFIG_FILE, - restMethodsForStoredProcedure: null, - graphQLOperationForStoredProcedure: null, - parametersNameCollection: null, - parametersDescriptionCollection: null, - parametersRequiredCollection: null, - parametersDefaultCollection: null, - fieldsNameCollection: [], - fieldsAliasCollection: [], - fieldsDescriptionCollection: [], - fieldsPrimaryKeyCollection: [], - mcpDmlTools: "false", - mcpCustomTool: null - ); - return ExecuteVerifyTest(options); + VerifySettings settings = new(); + settings.UseParameters(mcpDmlTools, source); + return ExecuteVerifyTest(options, settings: settings); } /// diff --git a/src/Cli.Tests/ModuleInitializer.cs b/src/Cli.Tests/ModuleInitializer.cs index e00dc00a89..a0c882ae74 100644 --- a/src/Cli.Tests/ModuleInitializer.cs +++ b/src/Cli.Tests/ModuleInitializer.cs @@ -65,6 +65,10 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); + // Ignore the UserProvidedCustomToolEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedCustomToolEnabled); + // Ignore the UserProvidedDmlToolsEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedDmlToolsEnabled); // Ignore the IsRequestBodyStrict as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsRequestBodyStrict); // Ignore the IsGraphQLEnabled as that's unimportant from a test standpoint. diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpProperties.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpProperties.verified.txt index 38dfae6840..d7d3ed0056 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpProperties.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpProperties.verified.txt @@ -53,9 +53,7 @@ ], Mcp: { CustomToolEnabled: true, - DmlToolEnabled: false, - UserProvidedCustomToolEnabled: true, - UserProvidedDmlToolsEnabled: true + DmlToolEnabled: false } } } diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpPropertiesEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpPropertiesEnabled.verified.txt index f87a181a24..aa30025561 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpPropertiesEnabled.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpPropertiesEnabled.verified.txt @@ -53,9 +53,7 @@ ], Mcp: { CustomToolEnabled: true, - DmlToolEnabled: true, - UserProvidedCustomToolEnabled: true, - UserProvidedDmlToolsEnabled: true + DmlToolEnabled: true } } } diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt index 81628df772..576e84f6d8 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt @@ -53,9 +53,7 @@ ], Mcp: { CustomToolEnabled: true, - DmlToolEnabled: false, - UserProvidedCustomToolEnabled: true, - UserProvidedDmlToolsEnabled: false + DmlToolEnabled: true } } } diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=false_source=authors.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=false_source=authors.verified.txt new file mode 100644 index 0000000000..51a278d2a3 --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=false_source=authors.verified.txt @@ -0,0 +1,57 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [ + { + Author: { + Source: { + Object: authors, + Type: Table + }, + GraphQL: { + Singular: Author, + Plural: Authors, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: false + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=true_source=books.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=true_source=books.verified.txt new file mode 100644 index 0000000000..4dc41a4d45 --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=true_source=books.verified.txt @@ -0,0 +1,57 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [ + { + Book: { + Source: { + Object: books, + Type: Table + }, + GraphQL: { + Singular: Book, + Plural: Books, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpProperties.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpProperties.verified.txt index e784664b66..627e8e9e01 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpProperties.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpProperties.verified.txt @@ -56,9 +56,7 @@ ], Mcp: { CustomToolEnabled: true, - DmlToolEnabled: false, - UserProvidedCustomToolEnabled: true, - UserProvidedDmlToolsEnabled: true + DmlToolEnabled: false } } } diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpPropertiesEnabled.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpPropertiesEnabled.verified.txt index 7c9fb41700..47d181d59d 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpPropertiesEnabled.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpPropertiesEnabled.verified.txt @@ -56,9 +56,7 @@ ], Mcp: { CustomToolEnabled: true, - DmlToolEnabled: true, - UserProvidedCustomToolEnabled: true, - UserProvidedDmlToolsEnabled: true + DmlToolEnabled: true } } } diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt index 7461ea4ae5..4cb7fb45ef 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt @@ -56,9 +56,7 @@ ], Mcp: { CustomToolEnabled: true, - DmlToolEnabled: false, - UserProvidedCustomToolEnabled: true, - UserProvidedDmlToolsEnabled: false + DmlToolEnabled: true } } } diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=false.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=false.verified.txt new file mode 100644 index 0000000000..42cb419190 --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=false.verified.txt @@ -0,0 +1,61 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + MyEntity: { + Source: { + Object: MyTable, + Type: Table + }, + GraphQL: { + Singular: MyEntity, + Plural: MyEntities, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: false + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=true.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=true.verified.txt new file mode 100644 index 0000000000..4084b29397 --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=true.verified.txt @@ -0,0 +1,61 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + MyEntity: { + Source: { + Object: MyTable, + Type: Table + }, + GraphQL: { + Singular: MyEntity, + Plural: MyEntities, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/UpdateEntityTests.cs b/src/Cli.Tests/UpdateEntityTests.cs index 9c6b81536a..2cc03dd8f8 100644 --- a/src/Cli.Tests/UpdateEntityTests.cs +++ b/src/Cli.Tests/UpdateEntityTests.cs @@ -1219,72 +1219,13 @@ private Task ExecuteVerifyTest(string initialConfig, UpdateOptions options, Veri #region MCP Entity Configuration Tests /// - /// Test updating table entity with MCP dml-tools enabled + /// Test updating table entity with MCP dml-tools from false to true, or true to false + /// Tests actual update scenario where existing MCP config is modified /// - [TestMethod] - public Task TestUpdateTableEntityWithMcpDmlToolsEnabled() - { - UpdateOptions options = new( - source: null, - permissions: null, - entity: "MyEntity", - sourceType: null, - sourceParameters: null, - sourceKeyFields: null, - restRoute: null, - graphQLType: null, - fieldsToInclude: null, - fieldsToExclude: null, - policyRequest: null, - policyDatabase: null, - relationship: null, - cardinality: null, - targetEntity: null, - linkingObject: null, - linkingSourceFields: null, - linkingTargetFields: null, - relationshipFields: null, - map: null, - cacheEnabled: null, - cacheTtl: null, - config: TEST_RUNTIME_CONFIG_FILE, - restMethodsForStoredProcedure: null, - graphQLOperationForStoredProcedure: null, - description: null, - parametersNameCollection: null, - parametersDescriptionCollection: null, - parametersRequiredCollection: null, - parametersDefaultCollection: null, - fieldsNameCollection: null, - fieldsAliasCollection: null, - fieldsDescriptionCollection: null, - fieldsPrimaryKeyCollection: null, - mcpDmlTools: "true", - mcpCustomTool: null - ); - - string initialConfig = GetInitialConfigString() + "," + @" - ""entities"": { - ""MyEntity"": { - ""source"": ""MyTable"", - ""permissions"": [ - { - ""role"": ""anonymous"", - ""actions"": [""*""] - } - ] - } - } - }"; - - return ExecuteVerifyTest(initialConfig, options); - } - - /// - /// Test updating table entity with MCP dml-tools disabled - /// - [TestMethod] - public Task TestUpdateTableEntityWithMcpDmlToolsDisabled() + [DataTestMethod] + [DataRow("true", "false", DisplayName = "TestUpdateTableEntityWithMcpDmlToolsEnabled")] + [DataRow("false", "true", DisplayName = "TestUpdateTableEntityWithMcpDmlToolsDisabled")] + public Task TestUpdateTableEntityWithMcpDmlTools(string newMcpDmlTools, string initialMcpDmlTools) { UpdateOptions options = new( source: null, @@ -1321,7 +1262,7 @@ public Task TestUpdateTableEntityWithMcpDmlToolsDisabled() fieldsAliasCollection: null, fieldsDescriptionCollection: null, fieldsPrimaryKeyCollection: null, - mcpDmlTools: "false", + mcpDmlTools: newMcpDmlTools, mcpCustomTool: null ); @@ -1334,16 +1275,20 @@ public Task TestUpdateTableEntityWithMcpDmlToolsDisabled() ""role"": ""anonymous"", ""actions"": [""*""] } - ] + ], + ""mcp"": " + initialMcpDmlTools + @" } } }"; - return ExecuteVerifyTest(initialConfig, options); + VerifySettings settings = new(); + settings.UseParameters(newMcpDmlTools); + return ExecuteVerifyTest(initialConfig, options, settings: settings); } /// - /// Test updating stored procedure with MCP custom-tool enabled + /// Test updating stored procedure with MCP custom-tool from false to true + /// Tests actual update scenario where existing MCP config is modified /// [TestMethod] public Task TestUpdateStoredProcedureWithMcpCustomToolEnabled() @@ -1399,7 +1344,10 @@ public Task TestUpdateStoredProcedureWithMcpCustomToolEnabled() ""role"": ""anonymous"", ""actions"": [""execute""] } - ] + ], + ""mcp"": { + ""custom-tool"": false + } } } }"; @@ -1409,6 +1357,7 @@ public Task TestUpdateStoredProcedureWithMcpCustomToolEnabled() /// /// Test updating stored procedure with both MCP properties + /// Updates from both true to custom-tool=true, dml-tools=false /// [TestMethod] public Task TestUpdateStoredProcedureWithBothMcpProperties() @@ -1464,7 +1413,11 @@ public Task TestUpdateStoredProcedureWithBothMcpProperties() ""role"": ""anonymous"", ""actions"": [""execute""] } - ] + ], + ""mcp"": { + ""custom-tool"": false, + ""dml-tools"": true + } } } }"; @@ -1474,6 +1427,7 @@ public Task TestUpdateStoredProcedureWithBothMcpProperties() /// /// Test updating stored procedure with both MCP properties enabled + /// Updates from both false to both true /// [TestMethod] public Task TestUpdateStoredProcedureWithBothMcpPropertiesEnabled() @@ -1529,7 +1483,11 @@ public Task TestUpdateStoredProcedureWithBothMcpPropertiesEnabled() ""role"": ""anonymous"", ""actions"": [""execute""] } - ] + ], + ""mcp"": { + ""custom-tool"": false, + ""dml-tools"": false + } } } }"; diff --git a/src/Cli/Commands/EntityOptions.cs b/src/Cli/Commands/EntityOptions.cs index 700bb051eb..3b2b77d9b2 100644 --- a/src/Cli/Commands/EntityOptions.cs +++ b/src/Cli/Commands/EntityOptions.cs @@ -137,10 +137,10 @@ public EntityOptions( [Option("fields.primary-key", Required = false, Separator = ',', HelpText = "Set this field as a primary key.")] public IEnumerable? FieldsPrimaryKeyCollection { get; } - [Option("mcp.dml-tools", Required = false, HelpText = "Enable MCP DML (Data Manipulation Language) tools for this entity. Allows CRUD operations via MCP.")] + [Option("mcp.dml-tools", Required = false, HelpText = "Enable MCP DML (Data Manipulation Language) tools for this entity. Allows CRUD operations via MCP. Default value is true.")] public string? McpDmlTools { get; } - [Option("mcp.custom-tool", Required = false, HelpText = "Enable MCP custom tool for this entity. Only valid for stored procedures.")] + [Option("mcp.custom-tool", Required = false, HelpText = "Enable MCP custom tool for this entity. Only valid for stored procedures. Default value is false.")] public string? McpCustomTool { get; } } } diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index cd532a9552..8fcc7cae31 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -449,12 +449,13 @@ public static bool TryAddNewEntity(AddOptions options, RuntimeConfig initialRunt EntityRestOptions restOptions = ConstructRestOptions(options.RestRoute, SupportedRestMethods, initialRuntimeConfig.DataSource.DatabaseType == DatabaseType.CosmosDB_NoSQL); EntityGraphQLOptions graphqlOptions = ConstructGraphQLTypeDetails(options.GraphQLType, graphQLOperationsForStoredProcedures); EntityCacheOptions? cacheOptions = ConstructCacheOptions(options.CacheEnabled, options.CacheTtl); + EntityMcpOptions? mcpOptions = null; - EntityMcpOptions? mcpOptionsToUse = null; if (options.McpDmlTools is not null || options.McpCustomTool is not null) { - mcpOptionsToUse = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedure); - if (mcpOptionsToUse is null) + mcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedure); + + if (mcpOptions is null) { _logger.LogError("Failed to construct MCP options."); return false; @@ -472,7 +473,7 @@ public static bool TryAddNewEntity(AddOptions options, RuntimeConfig initialRunt Mappings: null, Cache: cacheOptions, Description: string.IsNullOrWhiteSpace(options.Description) ? null : options.Description, - Mcp: mcpOptionsToUse); + Mcp: mcpOptions); // Add entity to existing runtime config. IDictionary entities = new Dictionary(initialRuntimeConfig.Entities.Entities) diff --git a/src/Config/Converters/EntityMcpOptionsConverterFactory.cs b/src/Config/Converters/EntityMcpOptionsConverterFactory.cs index 1cab0b9cc9..b4ad0e9170 100644 --- a/src/Config/Converters/EntityMcpOptionsConverterFactory.cs +++ b/src/Config/Converters/EntityMcpOptionsConverterFactory.cs @@ -59,7 +59,7 @@ private class EntityMcpOptionsConverter : JsonConverter { if (reader.TokenType == JsonTokenType.EndObject) { - break; + return new EntityMcpOptions(customToolEnabled, dmlToolsEnabled); } if (reader.TokenType == JsonTokenType.PropertyName) @@ -80,8 +80,6 @@ private class EntityMcpOptionsConverter : JsonConverter } } } - - return new EntityMcpOptions(customToolEnabled, dmlToolsEnabled); } throw new JsonException($"Unexpected token type {reader.TokenType} for EntityMcpOptions"); diff --git a/src/Config/DeserializationVariableReplacementSettings.cs b/src/Config/DeserializationVariableReplacementSettings.cs index 5c70f4082b..350824409b 100644 --- a/src/Config/DeserializationVariableReplacementSettings.cs +++ b/src/Config/DeserializationVariableReplacementSettings.cs @@ -259,7 +259,7 @@ private static SecretClient CreateSecretClient(AzureKeyVaultOptions options) clientOptions.Retry.NetworkTimeout = TimeSpan.FromSeconds(options.RetryPolicy.NetworkTimeoutSeconds ?? AKVRetryPolicyOptions.DEFAULT_NETWORK_TIMEOUT_SECONDS); } - return new SecretClient(new Uri(options.Endpoint), new DefaultAzureCredential(), clientOptions); + return new SecretClient(new Uri(options.Endpoint), new DefaultAzureCredential(), clientOptions); // CodeQL [SM05137] DefaultAzureCredential will use Managed Identity if available or fallback to default. } private string? GetAkvVariable(string name) diff --git a/src/Config/ObjectModel/EntityMcpOptions.cs b/src/Config/ObjectModel/EntityMcpOptions.cs index 5485926e39..ad928a21ab 100644 --- a/src/Config/ObjectModel/EntityMcpOptions.cs +++ b/src/Config/ObjectModel/EntityMcpOptions.cs @@ -19,10 +19,10 @@ public record EntityMcpOptions /// /// Indicates whether DML tools are enabled for this entity. - /// Defaults to false when not explicitly provided. + /// Defaults to true when not explicitly provided. /// [JsonPropertyName("dml-tools")] - public bool DmlToolEnabled { get; init; } = false; + public bool DmlToolEnabled { get; init; } = true; /// /// Flag which informs CLI and JSON serializer whether to write the CustomToolEnabled diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index bad5aa8680..6d6cf6d51b 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -314,6 +314,7 @@ public static JsonSerializerOptions GetSerializationOptions( options.Converters.Add(new EntityActionConverterFactory()); options.Converters.Add(new DataSourceFilesConverter()); options.Converters.Add(new EntityCacheOptionsConverterFactory(replacementSettings)); + options.Converters.Add(new EntityMcpOptionsConverterFactory()); options.Converters.Add(new RuntimeCacheOptionsConverterFactory()); options.Converters.Add(new RuntimeCacheLevel2OptionsConverterFactory()); options.Converters.Add(new MultipleCreateOptionsConverter()); diff --git a/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs b/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs index cf0edc4ece..5ce34c9355 100644 --- a/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs +++ b/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs @@ -14,6 +14,51 @@ namespace Azure.DataApiBuilder.Service.Tests.Configuration [TestClass] public class EntityMcpConfigurationTests { + private const string BASE_CONFIG_TEMPLATE = @"{{ + ""$schema"": ""test-schema"", + ""data-source"": {{ + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }}, + ""runtime"": {{ + ""rest"": {{ ""enabled"": true, ""path"": ""/api"" }}, + ""graphql"": {{ ""enabled"": true, ""path"": ""/graphql"" }}, + ""host"": {{ ""mode"": ""development"" }} + }}, + ""entities"": {{ + {0} + }} + }}"; + + /// + /// Helper method to create a config with specified entities JSON + /// + private static string CreateConfig(string entitiesJson) + { + return string.Format(BASE_CONFIG_TEMPLATE, entitiesJson); + } + + /// + /// Helper method to assert entity MCP configuration + /// + private static void AssertEntityMcp(Entity entity, bool? expectedDmlTools, bool? expectedCustomTool, string message = null) + { + if (expectedDmlTools == null && expectedCustomTool == null) + { + Assert.IsNull(entity.Mcp, "MCP options should be null when not specified"); + return; + } + + Assert.IsNotNull(entity.Mcp, message ?? "MCP options should be present"); + + bool actualDmlTools = entity.Mcp?.DmlToolEnabled ?? true; // Default is true + bool actualCustomTool = entity.Mcp?.CustomToolEnabled ?? false; // Default is false + + Assert.AreEqual(expectedDmlTools ?? true, actualDmlTools, + $"DmlToolEnabled should be {expectedDmlTools ?? true}"); + Assert.AreEqual(expectedCustomTool ?? false, actualCustomTool, + $"CustomToolEnabled should be {expectedCustomTool ?? false}"); + } /// /// Test that deserializing boolean 'true' shorthand correctly sets dml-tools enabled. /// @@ -21,25 +66,13 @@ public class EntityMcpConfigurationTests public void DeserializeConfig_McpBooleanTrue_EnablesDmlToolsOnly() { // Arrange - string config = @"{ - ""$schema"": ""test-schema"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""test"" - }, - ""runtime"": { - ""rest"": { ""enabled"": true, ""path"": ""/api"" }, - ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, - ""host"": { ""mode"": ""development"" } - }, - ""entities"": { - ""Book"": { - ""source"": ""books"", - ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], - ""mcp"": true - } + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": true } - }"; + "); // Act bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); @@ -48,11 +81,7 @@ public void DeserializeConfig_McpBooleanTrue_EnablesDmlToolsOnly() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); - - Entity bookEntity = runtimeConfig.Entities["Book"]; - Assert.IsNotNull(bookEntity.Mcp, "MCP options should be present"); - Assert.IsTrue(bookEntity.Mcp.DmlToolEnabled, "DmlTools should be enabled"); - Assert.IsFalse(bookEntity.Mcp.CustomToolEnabled, "CustomTool should be disabled (default)"); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: true, expectedCustomTool: false); } /// @@ -62,25 +91,13 @@ public void DeserializeConfig_McpBooleanTrue_EnablesDmlToolsOnly() public void DeserializeConfig_McpBooleanFalse_DisablesDmlTools() { // Arrange - string config = @"{ - ""$schema"": ""test-schema"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""test"" - }, - ""runtime"": { - ""rest"": { ""enabled"": true, ""path"": ""/api"" }, - ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, - ""host"": { ""mode"": ""development"" } - }, - ""entities"": { - ""Book"": { - ""source"": ""books"", - ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], - ""mcp"": false - } + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": false } - }"; + "); // Act bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); @@ -89,11 +106,7 @@ public void DeserializeConfig_McpBooleanFalse_DisablesDmlTools() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); - - Entity bookEntity = runtimeConfig.Entities["Book"]; - Assert.IsNotNull(bookEntity.Mcp, "MCP options should be present"); - Assert.IsFalse(bookEntity.Mcp.DmlToolEnabled, "DmlTools should be disabled"); - Assert.IsFalse(bookEntity.Mcp.CustomToolEnabled, "CustomTool should be disabled (default)"); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: false, expectedCustomTool: false); } /// @@ -103,28 +116,16 @@ public void DeserializeConfig_McpBooleanFalse_DisablesDmlTools() public void DeserializeConfig_McpObject_SetsBothProperties() { // Arrange - string config = @"{ - ""$schema"": ""test-schema"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""test"" - }, - ""runtime"": { - ""rest"": { ""enabled"": true, ""path"": ""/api"" }, - ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, - ""host"": { ""mode"": ""development"" } - }, - ""entities"": { - ""GetBook"": { - ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, - ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], - ""mcp"": { - ""custom-tool"": true, - ""dml-tools"": false - } + string config = CreateConfig(@" + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": true, + ""dml-tools"": false } } - }"; + "); // Act bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); @@ -133,11 +134,7 @@ public void DeserializeConfig_McpObject_SetsBothProperties() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); - - Entity spEntity = runtimeConfig.Entities["GetBook"]; - Assert.IsNotNull(spEntity.Mcp, "MCP options should be present"); - Assert.IsTrue(spEntity.Mcp.CustomToolEnabled, "CustomTool should be enabled"); - Assert.IsFalse(spEntity.Mcp.DmlToolEnabled, "DmlTools should be disabled"); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: false, expectedCustomTool: true); } /// @@ -147,27 +144,15 @@ public void DeserializeConfig_McpObject_SetsBothProperties() public void DeserializeConfig_McpObjectWithDmlToolsOnly_WorksCorrectly() { // Arrange - string config = @"{ - ""$schema"": ""test-schema"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""test"" - }, - ""runtime"": { - ""rest"": { ""enabled"": true, ""path"": ""/api"" }, - ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, - ""host"": { ""mode"": ""development"" } - }, - ""entities"": { - ""Book"": { - ""source"": ""books"", - ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], - ""mcp"": { - ""dml-tools"": true - } + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": { + ""dml-tools"": true } } - }"; + "); // Act bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); @@ -176,38 +161,22 @@ public void DeserializeConfig_McpObjectWithDmlToolsOnly_WorksCorrectly() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); - - Entity bookEntity = runtimeConfig.Entities["Book"]; - Assert.IsNotNull(bookEntity.Mcp, "MCP options should be present"); - Assert.IsTrue(bookEntity.Mcp.DmlToolEnabled, "DmlTools should be enabled"); - Assert.IsFalse(bookEntity.Mcp.CustomToolEnabled, "CustomTool should be disabled (default)"); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: true, expectedCustomTool: false); } /// - /// Test that entity without MCP configuration has null MCP options. + /// Test that entity without MCP configuration has null Mcp property. /// [TestMethod] public void DeserializeConfig_NoMcp_HasNullMcpOptions() { // Arrange - string config = @"{ - ""$schema"": ""test-schema"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""test"" - }, - ""runtime"": { - ""rest"": { ""enabled"": true, ""path"": ""/api"" }, - ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, - ""host"": { ""mode"": ""development"" } - }, - ""entities"": { - ""Book"": { - ""source"": ""books"", - ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }] - } + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }] } - }"; + "); // Act bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); @@ -216,9 +185,7 @@ public void DeserializeConfig_NoMcp_HasNullMcpOptions() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); - - Entity bookEntity = runtimeConfig.Entities["Book"]; - Assert.IsNull(bookEntity.Mcp, "MCP options should be null when not specified"); + Assert.IsNull(runtimeConfig.Entities["Book"].Mcp, "MCP options should be null when not specified"); } /// @@ -228,28 +195,16 @@ public void DeserializeConfig_NoMcp_HasNullMcpOptions() public void DeserializeConfig_McpObjectWithBothTrue_SetsCorrectly() { // Arrange - string config = @"{ - ""$schema"": ""test-schema"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""test"" - }, - ""runtime"": { - ""rest"": { ""enabled"": true, ""path"": ""/api"" }, - ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, - ""host"": { ""mode"": ""development"" } - }, - ""entities"": { - ""GetBook"": { - ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, - ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], - ""mcp"": { - ""custom-tool"": true, - ""dml-tools"": true - } + string config = CreateConfig(@" + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": true, + ""dml-tools"": true } } - }"; + "); // Act bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); @@ -258,11 +213,7 @@ public void DeserializeConfig_McpObjectWithBothTrue_SetsCorrectly() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); - - Entity spEntity = runtimeConfig.Entities["GetBook"]; - Assert.IsNotNull(spEntity.Mcp, "MCP options should be present"); - Assert.IsTrue(spEntity.Mcp.CustomToolEnabled, "CustomTool should be enabled"); - Assert.IsTrue(spEntity.Mcp.DmlToolEnabled, "DmlTools should be enabled"); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: true, expectedCustomTool: true); } /// @@ -272,28 +223,16 @@ public void DeserializeConfig_McpObjectWithBothTrue_SetsCorrectly() public void DeserializeConfig_McpObjectWithBothFalse_SetsCorrectly() { // Arrange - string config = @"{ - ""$schema"": ""test-schema"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""test"" - }, - ""runtime"": { - ""rest"": { ""enabled"": true, ""path"": ""/api"" }, - ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, - ""host"": { ""mode"": ""development"" } - }, - ""entities"": { - ""GetBook"": { - ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, - ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], - ""mcp"": { - ""custom-tool"": false, - ""dml-tools"": false - } + string config = CreateConfig(@" + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": false, + ""dml-tools"": false } } - }"; + "); // Act bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); @@ -302,11 +241,7 @@ public void DeserializeConfig_McpObjectWithBothFalse_SetsCorrectly() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); - - Entity spEntity = runtimeConfig.Entities["GetBook"]; - Assert.IsNotNull(spEntity.Mcp, "MCP options should be present"); - Assert.IsFalse(spEntity.Mcp.CustomToolEnabled, "CustomTool should be disabled"); - Assert.IsFalse(spEntity.Mcp.DmlToolEnabled, "DmlTools should be disabled"); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: false, expectedCustomTool: false); } /// @@ -316,27 +251,15 @@ public void DeserializeConfig_McpObjectWithBothFalse_SetsCorrectly() public void DeserializeConfig_McpObjectWithCustomToolOnly_WorksCorrectly() { // Arrange - string config = @"{ - ""$schema"": ""test-schema"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""test"" - }, - ""runtime"": { - ""rest"": { ""enabled"": true, ""path"": ""/api"" }, - ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, - ""host"": { ""mode"": ""development"" } - }, - ""entities"": { - ""GetBook"": { - ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, - ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], - ""mcp"": { - ""custom-tool"": true - } + string config = CreateConfig(@" + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": true } } - }"; + "); // Act bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); @@ -345,11 +268,7 @@ public void DeserializeConfig_McpObjectWithCustomToolOnly_WorksCorrectly() Assert.IsTrue(success, "Config should parse successfully"); Assert.IsNotNull(runtimeConfig); Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); - - Entity spEntity = runtimeConfig.Entities["GetBook"]; - Assert.IsNotNull(spEntity.Mcp, "MCP options should be present"); - Assert.IsTrue(spEntity.Mcp.CustomToolEnabled, "CustomTool should be enabled"); - Assert.IsFalse(spEntity.Mcp.DmlToolEnabled, "DmlTools should be disabled (default is false)"); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: true, expectedCustomTool: true); } /// @@ -405,29 +324,19 @@ public void DeserializeConfig_MultipleEntitiesWithDifferentMcpSettings_WorksCorr // Book: mcp = true Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); - Entity bookEntity = runtimeConfig.Entities["Book"]; - Assert.IsNotNull(bookEntity.Mcp); - Assert.IsTrue(bookEntity.Mcp.DmlToolEnabled); - Assert.IsFalse(bookEntity.Mcp.CustomToolEnabled); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: true, expectedCustomTool: false); // Author: mcp = false Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Author")); - Entity authorEntity = runtimeConfig.Entities["Author"]; - Assert.IsNotNull(authorEntity.Mcp); - Assert.IsFalse(authorEntity.Mcp.DmlToolEnabled); - Assert.IsFalse(authorEntity.Mcp.CustomToolEnabled); + AssertEntityMcp(runtimeConfig.Entities["Author"], expectedDmlTools: false, expectedCustomTool: false); - // Publisher: no mcp + // Publisher: no mcp (null) Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Publisher")); - Entity publisherEntity = runtimeConfig.Entities["Publisher"]; - Assert.IsNull(publisherEntity.Mcp); + Assert.IsNull(runtimeConfig.Entities["Publisher"].Mcp, "Mcp should be null when not specified"); // GetBook: mcp object Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); - Entity spEntity = runtimeConfig.Entities["GetBook"]; - Assert.IsNotNull(spEntity.Mcp); - Assert.IsTrue(spEntity.Mcp.CustomToolEnabled); - Assert.IsFalse(spEntity.Mcp.DmlToolEnabled); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: false, expectedCustomTool: true); } /// @@ -437,25 +346,13 @@ public void DeserializeConfig_MultipleEntitiesWithDifferentMcpSettings_WorksCorr public void DeserializeConfig_InvalidMcpValue_FailsGracefully() { // Arrange - string config = @"{ - ""$schema"": ""test-schema"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""test"" - }, - ""runtime"": { - ""rest"": { ""enabled"": true, ""path"": ""/api"" }, - ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, - ""host"": { ""mode"": ""development"" } - }, - ""entities"": { - ""Book"": { - ""source"": ""books"", - ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], - ""mcp"": ""invalid"" - } + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": ""invalid"" } - }"; + "); // Act bool success = RuntimeConfigLoader.TryParseConfig(config, out _); @@ -471,28 +368,16 @@ public void DeserializeConfig_InvalidMcpValue_FailsGracefully() public void DeserializeConfig_McpObjectWithUnknownProperty_FailsGracefully() { // Arrange - string config = @"{ - ""$schema"": ""test-schema"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""test"" - }, - ""runtime"": { - ""rest"": { ""enabled"": true, ""path"": ""/api"" }, - ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, - ""host"": { ""mode"": ""development"" } - }, - ""entities"": { - ""Book"": { - ""source"": ""books"", - ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], - ""mcp"": { - ""dml-tools"": true, - ""unknown-property"": true - } + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": { + ""dml-tools"": true, + ""unknown-property"": true } } - }"; + "); // Act bool success = RuntimeConfigLoader.TryParseConfig(config, out _); diff --git a/src/Service.Tests/ModuleInitializer.cs b/src/Service.Tests/ModuleInitializer.cs index ba0407ecd5..f0c3984a72 100644 --- a/src/Service.Tests/ModuleInitializer.cs +++ b/src/Service.Tests/ModuleInitializer.cs @@ -69,6 +69,10 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); + // Ignore the UserProvidedCustomToolEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedCustomToolEnabled); + // Ignore the UserProvidedDmlToolsEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedDmlToolsEnabled); // Ignore the CosmosDataSourceUsed as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.CosmosDataSourceUsed); // Ignore the IsRequestBodyStrict as that's unimportant from a test standpoint. From a3e3dc124617951a375151e4d35866c7d040da4c Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Fri, 19 Dec 2025 10:42:21 +0530 Subject: [PATCH 10/15] POC tests and artifacts --- TEST-RESULTS.md | 180 ++++++++++++++++++++ src/Service.Tests/dab-config.MsSql.json | 36 +++- test-custom-tools.ps1 | 214 ++++++++++++++++++++++++ test-descriptions.ps1 | 83 +++++++++ test-edge-cases.ps1 | 178 ++++++++++++++++++++ test-permissions.ps1 | 156 +++++++++++++++++ 6 files changed, 838 insertions(+), 9 deletions(-) create mode 100644 TEST-RESULTS.md create mode 100644 test-custom-tools.ps1 create mode 100644 test-descriptions.ps1 create mode 100644 test-edge-cases.ps1 create mode 100644 test-permissions.ps1 diff --git a/TEST-RESULTS.md b/TEST-RESULTS.md new file mode 100644 index 0000000000..04d3be5551 --- /dev/null +++ b/TEST-RESULTS.md @@ -0,0 +1,180 @@ +# Custom MCP Tools - Test Results Summary + +**Date:** December 18, 2025 +**Branch:** Usr/sogh/entity-level-mcp-config +**POC Status:** ✅ PASSED + +## Overview +Comprehensive testing of the dynamic custom MCP tools POC that elevates stored procedures to dedicated MCP tools based on configuration. + +## Test Configuration +- **Custom Tools Enabled:** 4 stored procedures + - `get_books` - Parameterless query + - `get_book` - Query with @id parameter + - `insert_book` - Mutation with @title and @publisher_id parameters + - `count_books` - Parameterless aggregation + +## Test Results + +### ✅ Core Functionality (12/12 Passed) + +1. **Tool Discovery** + - ✅ All 4 custom tools appear in `tools/list` + - ✅ Tool names correctly converted to lowercase_underscore format + - ✅ Input schemas generated with correct parameter types + +2. **Parameterless Execution** + - ✅ `get_books` returns complete book list (30 books) + - ✅ `count_books` returns correct count (24 books after inserts) + +3. **Parameterized Execution** + - ✅ `get_book` with valid id (id=1) returns correct book + - ✅ `get_book` with non-existent id (id=999999) returns empty array gracefully + +4. **Parameter Validation** + - ✅ Missing required parameter (@id) produces proper SQL error + - ✅ Extra unexpected parameters ignored gracefully + +5. **Data Mutation** + - ✅ `insert_book` successfully inserts new records + - ✅ Partial parameters use default values from config + - ✅ Book count incremented correctly after inserts (22 → 24) + +6. **Error Handling** + - ✅ Invalid publisher_id rejected with FK constraint error + - ✅ Non-existent tool name returns proper JSON-RPC error + - ✅ Invalid parameter types produce appropriate SQL errors + +### ✅ Edge Cases (10/10 Passed) + +1. **SQL Injection Protection** + - ✅ Input: `id = "1; DROP TABLE books; --"` + - ✅ Result: Parameterized safely, conversion error returned + +2. **Large Data Handling** + - ✅ 10,000 character string inserted successfully + - ✅ No truncation or crashes + +3. **Special Characters** + - ✅ Quotes, double quotes, angle brackets handled correctly + - ✅ Empty strings accepted and stored + +4. **Type Handling** + - ✅ String value for integer parameter produces type error + - ✅ Negative IDs handled gracefully (returns empty) + - ✅ Int32.MaxValue processed correctly + +5. **Tool Name Case Sensitivity** + - ✅ `GET_BOOKS` (uppercase) rejected with "Unknown tool" error + - ✅ Tool names are case-sensitive as expected + +### ✅ Permission & Authorization (4/4 Passed) + +1. **Anonymous Access** + - ✅ Anonymous role can execute `get_books` + - ✅ Anonymous role can list tools + +2. **Authenticated Access** + - ✅ Authenticated role can execute `insert_book` + +3. **Concurrency** + - ✅ 5 parallel requests all succeeded + - ✅ No race conditions or deadlocks + +## Key Findings + +### ✅ Strengths +1. **SQL Injection Protection:** Parameterized queries work correctly +2. **Error Handling:** Proper SQL and validation errors returned +3. **Data Integrity:** Foreign key constraints enforced +4. **Concurrency:** Multiple simultaneous requests handled well +5. **Special Characters:** Quotes, brackets, empty strings all work +6. **Large Data:** 10K character strings processed successfully + +### ⚠️ Areas for Enhancement (Future PRs) + +1. **Parameter Schema Generation:** + - `get_book` shows empty schema even though it requires @id parameter + - Only parameters with default values in config appear in schema + - Need to extract parameter info from stored procedure metadata + +2. **Type Information:** + - All parameters currently shown as "string" type in schema + - Should reflect actual SQL parameter types (int, varchar, etc.) + +3. **Description Quality:** + - Generic descriptions: "Execute {EntityName} stored procedure" + - Could be enhanced with stored procedure comments or annotations + +4. **Required vs Optional:** + - Schema doesn't indicate which parameters are required + - All parameters treated as optional if they have defaults + +## Response Format Examples + +### Successful Execution +```json +{ + "entity": "GetBooks", + "message": "Execution successful", + "value": { + "value": [ + { "id": 1, "title": "Awesome book", "publisher_id": 1234 } + ] + }, + "status": "success" +} +``` + +### Error Response +```json +{ + "toolName": "get_book", + "status": "error", + "error": { + "type": "ExecutionError", + "message": "Procedure or function 'get_book_by_id' expects parameter '@id', which was not supplied." + } +} +``` + +### JSON-RPC Error +```json +{ + "error": { + "code": -32603, + "message": "Unknown tool: 'non_existent_tool'" + }, + "id": 10, + "jsonrpc": "2.0" +} +``` + +## Performance Notes +- Tool registration occurs at startup (single-time cost) +- No noticeable latency in tool listing +- Execution performance matches regular stored procedure calls +- Concurrent requests handled without degradation + +## Recommendations for PR 1 + +1. **Keep Current POC Approach:** + - Simple factory pattern works well + - Delegation to existing execute_entity logic is solid + - Lowercase_underscore naming convention appropriate + +2. **Add Unit Tests For:** + - Tool name conversion (GetBooks → get_books) + - Schema generation for various parameter types + - Error scenarios (missing entity, invalid config) + - Collision detection (duplicate tool names) + +3. **Document Limitations:** + - Parameter info limited to config defaults (not extracted from DB) + - No hot-reload support yet (requires restart) + - No custom descriptions (uses generic template) + +## Conclusion +The POC successfully demonstrates the core concept of dynamic custom tool generation. All functional requirements are met, with proper error handling, security, and performance. Ready to proceed with structured PR implementation. + +**Next Step:** Begin PR 1 - Core Infrastructure with comprehensive unit tests and enhanced parameter schema generation. diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 798a343d47..37a98967f3 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -2855,6 +2855,7 @@ } }, "GetBooks": { + "description": "Retrieves all books from the library database with their titles and publisher information", "source": { "object": "get_books", "type": "stored-procedure" @@ -2890,15 +2891,20 @@ } ] } - ], - "mcp": { - "custom-tool": true - } + ] }, "GetBook": { + "description": "Retrieves a specific book by its unique identifier", "source": { "object": "get_book_by_id", - "type": "stored-procedure" + "type": "stored-procedure", + "parameters": [ + { + "name": "id", + "required": true, + "description": "The unique identifier of the book to retrieve" + } + ] }, "graphql": { "enabled": false, @@ -2975,13 +2981,24 @@ ] }, "InsertBook": { + "description": "Adds a new book to the library database with the specified title and publisher", "source": { "object": "insert_book", "type": "stored-procedure", - "parameters": { - "title": "randomX", - "publisher_id": 1234 - } + "parameters": [ + { + "name": "title", + "required": true, + "default": "randomX", + "description": "The title of the book to insert" + }, + { + "name": "publisher_id", + "required": false, + "default": "1234", + "description": "The unique identifier of the publisher" + } + ] }, "graphql": { "enabled": true, @@ -3020,6 +3037,7 @@ } }, "CountBooks": { + "description": "Returns the total count of books in the library database", "source": { "object": "count_books", "type": "stored-procedure" diff --git a/test-custom-tools.ps1 b/test-custom-tools.ps1 new file mode 100644 index 0000000000..5dfd9a1b61 --- /dev/null +++ b/test-custom-tools.ps1 @@ -0,0 +1,214 @@ +# Test script for MCP Custom Tools +# Tests various scenarios and edge cases + +# Bypass SSL certificate validation for local testing +add-type @" + using System.Net; + using System.Security.Cryptography.X509Certificates; + public class TrustAllCertsPolicy : ICertificatePolicy { + public bool CheckValidationResult( + ServicePoint srvPoint, X509Certificate certificate, + WebRequest request, int certificateProblem) { + return true; + } + } +"@ +[System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy + +$baseUrl = "https://localhost:5001/mcp" +$headers = @{ + "Content-Type" = "application/json" + "Accept" = "application/json, text/event-stream" +} + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "MCP Custom Tools - Test Suite" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# Helper function to make MCP requests +function Invoke-McpRequest { + param( + [string]$Method, + [object]$Params, + [int]$Id = 1 + ) + + $body = @{ + jsonrpc = "2.0" + method = $Method + params = $Params + id = $Id + } | ConvertTo-Json -Depth 10 + + try { + $response = Invoke-RestMethod -Uri $baseUrl -Method Post -Headers $headers -Body $body + return $response + } + catch { + Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red + if ($_.ErrorDetails.Message) { + Write-Host "Details: $($_.ErrorDetails.Message)" -ForegroundColor Red + } + return $null + } +} + +# Test 1: List all tools +Write-Host "Test 1: List all tools" -ForegroundColor Yellow +Write-Host "Expected: Should see get_books, get_book, insert_book, count_books in the list" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/list" -Params @{} +if ($response) { + $customTools = $response.result.tools | Where-Object { $_.name -match "^(get_books|get_book|insert_book|count_books)$" } + Write-Host "Custom tools found: $($customTools.Count)" -ForegroundColor Green + $customTools | ForEach-Object { + Write-Host " - $($_.name): $($_.description)" -ForegroundColor White + Write-Host " Input Schema: $($_.inputSchema | ConvertTo-Json -Compress)" -ForegroundColor Gray + } +} +Write-Host "" + +# Test 2: Call get_books (no parameters) +Write-Host "Test 2: Call get_books (no parameters)" -ForegroundColor Yellow +Write-Host "Expected: Should return list of books" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ name = "get_books" } -Id 2 +if ($response) { + Write-Host "Success! Response:" -ForegroundColor Green + Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White +} +Write-Host "" + +# Test 3: Call count_books (no parameters) +Write-Host "Test 3: Call count_books (no parameters)" -ForegroundColor Yellow +Write-Host "Expected: Should return total count of books" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ name = "count_books" } -Id 3 +if ($response) { + Write-Host "Success! Response:" -ForegroundColor Green + Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White +} +Write-Host "" + +# Test 4: Call get_book with parameter (id=1) +Write-Host "Test 4: Call get_book with parameter (id=1)" -ForegroundColor Yellow +Write-Host "Expected: Should return book with id=1" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "get_book" + arguments = @{ id = 1 } +} -Id 4 +if ($response) { + Write-Host "Success! Response:" -ForegroundColor Green + Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White +} +Write-Host "" + +# Test 5: Call get_book with non-existent id +Write-Host "Test 5: Call get_book with non-existent id (id=999999)" -ForegroundColor Yellow +Write-Host "Expected: Should return empty result or handle gracefully" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "get_book" + arguments = @{ id = 999999 } +} -Id 5 +if ($response) { + Write-Host "Response:" -ForegroundColor Green + Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White +} +Write-Host "" + +# Test 6: Call get_book without required parameter +Write-Host "Test 6: Call get_book without required parameter" -ForegroundColor Yellow +Write-Host "Expected: Should fail with parameter error" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "get_book" +} -Id 6 +if ($response) { + Write-Host "Response:" -ForegroundColor Green + Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White +} +Write-Host "" + +# Test 7: Call insert_book with parameters +Write-Host "Test 7: Call insert_book with parameters" -ForegroundColor Yellow +Write-Host "Expected: Should insert a new book" -ForegroundColor Gray +$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "insert_book" + arguments = @{ + title = "MCP Test Book $timestamp" + publisher_id = "1234" + } +} -Id 7 +if ($response) { + Write-Host "Success! Response:" -ForegroundColor Green + Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White +} +Write-Host "" + +# Test 8: Call insert_book with only title (missing publisher_id) +Write-Host "Test 8: Call insert_book with only title (missing publisher_id)" -ForegroundColor Yellow +Write-Host "Expected: Should use default value from config (1234) or fail" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "insert_book" + arguments = @{ + title = "Test Book Missing Publisher" + } +} -Id 8 +if ($response) { + Write-Host "Response:" -ForegroundColor Green + Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White +} +Write-Host "" + +# Test 9: Call insert_book with invalid publisher_id +Write-Host "Test 9: Call insert_book with invalid publisher_id" -ForegroundColor Yellow +Write-Host "Expected: Should fail with foreign key constraint error" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "insert_book" + arguments = @{ + title = "Test Book Invalid Publisher" + publisher_id = "99999" + } +} -Id 9 +if ($response) { + Write-Host "Response:" -ForegroundColor Green + Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White +} +Write-Host "" + +# Test 10: Call non-existent custom tool +Write-Host "Test 10: Call non-existent custom tool" -ForegroundColor Yellow +Write-Host "Expected: Should fail with tool not found error" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "non_existent_tool" +} -Id 10 +if ($response) { + Write-Host "Response:" -ForegroundColor Green + Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White +} +Write-Host "" + +# Test 11: Verify count after inserts +Write-Host "Test 11: Verify count after inserts" -ForegroundColor Yellow +Write-Host "Expected: Should show updated count" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ name = "count_books" } -Id 11 +if ($response) { + Write-Host "Success! Response:" -ForegroundColor Green + Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White +} +Write-Host "" + +# Test 12: Call get_books again to see new books +Write-Host "Test 12: Call get_books to see newly inserted books" -ForegroundColor Yellow +Write-Host "Expected: Should include newly inserted books" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ name = "get_books" } -Id 12 +if ($response) { + Write-Host "Success! Response (first 5 books):" -ForegroundColor Green + $books = $response.result.content[0].text | ConvertFrom-Json + $books | Select-Object -First 5 | ForEach-Object { + Write-Host " Book: $($_.title) (ID: $($_.id), Publisher: $($_.publisher_id))" -ForegroundColor White + } +} +Write-Host "" + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Test Suite Complete" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan diff --git a/test-descriptions.ps1 b/test-descriptions.ps1 new file mode 100644 index 0000000000..fa3017c4b4 --- /dev/null +++ b/test-descriptions.ps1 @@ -0,0 +1,83 @@ +# Test enhanced custom tools with descriptions +# Bypass SSL validation +if (-not ([System.Management.Automation.PSTypeName]'TrustAllCertsPolicy4').Type) { + add-type @" + using System.Net; + using System.Security.Cryptography.X509Certificates; + public class TrustAllCertsPolicy4 : ICertificatePolicy { + public bool CheckValidationResult( + ServicePoint svcPoint, X509Certificate certificate, + WebRequest request, int certificateProblem) { + return true; + } + } +"@ + [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy4 +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +$baseUrl = "https://localhost:5001/mcp" +$headers = @{ + "Content-Type" = "application/json" + "Accept" = "application/json, text/event-stream" +} + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Enhanced Custom Tools - Description Test" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# Test 1: List tools and check descriptions +Write-Host "Test 1: List tools and verify descriptions" -ForegroundColor Yellow +$body = @{ + jsonrpc = "2.0" + method = "tools/list" + params = @{} + id = 1 +} | ConvertTo-Json + +try { + $response = Invoke-WebRequest -Uri $baseUrl -Method Post -Headers $headers -Body $body -UseBasicParsing + $content = $response.Content + + # Parse SSE format + if ($content -match 'data: (.+)') { + $jsonData = $matches[1] + $data = $jsonData | ConvertFrom-Json + + if ($data.result.tools) { + $customTools = $data.result.tools | Where-Object { $_.name -in @('get_books', 'get_book', 'insert_book', 'count_books') } + + Write-Host "" + Write-Host "Custom Tools Found:" -ForegroundColor Green + foreach ($tool in $customTools) { + Write-Host "" + Write-Host " Tool: $($tool.name)" -ForegroundColor Cyan + Write-Host " Description: $($tool.description)" -ForegroundColor White + + # Check for parameters + if ($tool.inputSchema.properties) { + $propNames = $tool.inputSchema.properties.PSObject.Properties.Name + if ($propNames.Count -gt 0) { + Write-Host " Parameters:" -ForegroundColor Yellow + foreach ($paramName in $propNames) { + $paramDesc = $tool.inputSchema.properties.$paramName.description + Write-Host " - $paramName : $paramDesc" -ForegroundColor Gray + } + } + } + } + + Write-Host "" + Write-Host "All custom tools have descriptions!" -ForegroundColor Green + } + } +} +catch { + Write-Host "✗ Error: $($_.Exception.Message)" -ForegroundColor Red +} + +Write-Host "" +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Description Test Complete" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan diff --git a/test-edge-cases.ps1 b/test-edge-cases.ps1 new file mode 100644 index 0000000000..31ac54d259 --- /dev/null +++ b/test-edge-cases.ps1 @@ -0,0 +1,178 @@ +# Edge case tests for custom MCP tools +# Bypass SSL validation +add-type @" + using System.Net; + using System.Security.Cryptography.X509Certificates; + public class TrustAllCertsPolicy : ICertificatePolicy { + public bool CheckValidationResult( + ServicePoint svcPoint, X509Certificate certificate, + WebRequest request, int certificateProblem) { + return true; + } + } +"@ +[System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +$baseUrl = "https://localhost:5001/mcp" +$headers = @{ + "Content-Type" = "application/json" + "Accept" = "application/json, text/event-stream" +} + +function Invoke-McpRequest { + param( + [string]$Method, + [hashtable]$Params, + [int]$Id + ) + + $body = @{ + jsonrpc = "2.0" + method = $Method + params = $Params + id = $Id + } | ConvertTo-Json -Depth 10 + + try { + $response = Invoke-WebRequest -Uri $baseUrl -Method Post -Headers $headers -Body $body -UseBasicParsing + return $response.Content + } + catch { + return $_.Exception.Message + } +} + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Custom MCP Tools - Edge Case Tests" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# Edge Case 1: SQL Injection attempt +Write-Host "Edge Case 1: SQL Injection attempt in parameters" -ForegroundColor Yellow +Write-Host "Expected: Should be safely parameterized, no SQL injection" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "get_book" + arguments = @{ + id = "1; DROP TABLE books; --" + } +} -Id 100 +Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green +Write-Host "" + +# Edge Case 2: Very large parameter value +Write-Host "Edge Case 2: Very large string parameter" -ForegroundColor Yellow +Write-Host "Expected: Should handle or reject gracefully" -ForegroundColor Gray +$largeString = "A" * 10000 +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "insert_book" + arguments = @{ + title = $largeString + publisher_id = "1234" + } +} -Id 101 +Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green +Write-Host "" + +# Edge Case 3: Special characters in parameters +Write-Host "Edge Case 3: Special characters in title" -ForegroundColor Yellow +Write-Host "Expected: Should handle special characters correctly" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "insert_book" + arguments = @{ + title = "Test Book with 'quotes' and `"double quotes`" and " + publisher_id = "1234" + } +} -Id 102 +Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green +Write-Host "" + +# Edge Case 4: Null/empty parameters +Write-Host "Edge Case 4: Empty string for title" -ForegroundColor Yellow +Write-Host "Expected: Should handle empty string" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "insert_book" + arguments = @{ + title = "" + publisher_id = "1234" + } +} -Id 103 +Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green +Write-Host "" + +# Edge Case 5: Wrong parameter type +Write-Host "Edge Case 5: String value for integer parameter" -ForegroundColor Yellow +Write-Host "Expected: Should convert or reject with type error" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "get_book" + arguments = @{ + id = "not_a_number" + } +} -Id 104 +Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green +Write-Host "" + +# Edge Case 6: Extra unexpected parameters +Write-Host "Edge Case 6: Extra parameters not in schema" -ForegroundColor Yellow +Write-Host "Expected: Should ignore extra parameters or fail gracefully" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "get_books" + arguments = @{ + unexpected_param = "value" + another_param = 123 + } +} -Id 105 +Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green +Write-Host "" + +# Edge Case 7: Unicode characters +Write-Host "Edge Case 7: Unicode characters in title" -ForegroundColor Yellow +Write-Host "Expected: Should handle Unicode correctly" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "insert_book" + arguments = @{ + title = "Test Unicode Book" + publisher_id = "1234" + } +} -Id 106 +Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green +Write-Host "" + +# Edge Case 8: Negative ID +Write-Host "Edge Case 8: Negative ID value" -ForegroundColor Yellow +Write-Host "Expected: Should return empty result" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "get_book" + arguments = @{ + id = -1 + } +} -Id 107 +Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green +Write-Host "" + +# Edge Case 9: Very large ID +Write-Host "Edge Case 9: Very large ID value (Int32.MaxValue)" -ForegroundColor Yellow +Write-Host "Expected: Should handle large integers" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "get_book" + arguments = @{ + id = 2147483647 + } +} -Id 108 +Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green +Write-Host "" + +# Edge Case 10: Case sensitivity in tool names +Write-Host "Edge Case 10: Wrong case in tool name" -ForegroundColor Yellow +Write-Host "Expected: Should fail - tool names are case sensitive" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "GET_BOOKS" + arguments = @{} +} -Id 109 +Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green +Write-Host "" + +Write-Host "" +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Edge Case Tests Complete" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan diff --git a/test-permissions.ps1 b/test-permissions.ps1 new file mode 100644 index 0000000000..41a1d555a0 --- /dev/null +++ b/test-permissions.ps1 @@ -0,0 +1,156 @@ +# Permission tests for custom MCP tools +# Test with different authentication roles + +$baseUrl = "https://localhost:5001/mcp" +$headers = @{ + "Content-Type" = "application/json" + "Accept" = "application/json, text/event-stream" +} + +# Bypass SSL validation +if (-not ([System.Management.Automation.PSTypeName]'TrustAllCertsPolicy2').Type) { + add-type @" + using System.Net; + using System.Security.Cryptography.X509Certificates; + public class TrustAllCertsPolicy2 : ICertificatePolicy { + public bool CheckValidationResult( + ServicePoint svcPoint, X509Certificate certificate, + WebRequest request, int certificateProblem) { + return true; + } + } +"@ + [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy2 +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +function Invoke-McpRequest { + param( + [string]$Method, + [hashtable]$Params, + [int]$Id, + [string]$Role = "anonymous" + ) + + $requestHeaders = $headers.Clone() + if ($Role -ne "anonymous") { + $requestHeaders["X-MS-CLIENT-PRINCIPAL"] = ConvertTo-Json @{ + userId = "test-user-123" + userRoles = @($Role) + claims = @() + identityProvider = "staticwebapps" + } -Compress + } + + $body = @{ + jsonrpc = "2.0" + method = $Method + params = $Params + id = $Id + } | ConvertTo-Json -Depth 10 + + try { + $response = Invoke-WebRequest -Uri $baseUrl -Method Post -Headers $requestHeaders -Body $body -UseBasicParsing + return $response.Content + } + catch { + return "ERROR: $($_.Exception.Message)" + } +} + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Custom MCP Tools - Permission Tests" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# Test 1: Anonymous role (default in config) +Write-Host "Test 1: Call get_books as anonymous user" -ForegroundColor Yellow +Write-Host "Expected: Should succeed - anonymous has execute permission" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "get_books" +} -Id 1 -Role "anonymous" +if ($response -like "*Execution successful*") { + Write-Host "✓ SUCCESS" -ForegroundColor Green +} else { + Write-Host "✗ FAILED" -ForegroundColor Red + Write-Host "Response: $($response.Substring(0, [Math]::Min(300, $response.Length)))" -ForegroundColor Red +} +Write-Host "" + +# Test 2: Authenticated role +Write-Host "Test 2: Call insert_book as authenticated user" -ForegroundColor Yellow +Write-Host "Expected: Should succeed - authenticated has execute permission" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/call" -Params @{ + name = "insert_book" + arguments = @{ + title = "Permission Test Book" + publisher_id = "1234" + } +} -Id 2 -Role "authenticated" +if ($response -like "*Execution successful*") { + Write-Host "✓ SUCCESS" -ForegroundColor Green +} else { + Write-Host "✗ FAILED" -ForegroundColor Red + Write-Host "Response: $($response.Substring(0, [Math]::Min(300, $response.Length)))" -ForegroundColor Red +} +Write-Host "" + +# Test 3: Check tools list is accessible anonymously +Write-Host "Test 3: List tools as anonymous" -ForegroundColor Yellow +Write-Host "Expected: Should see all custom tools" -ForegroundColor Gray +$response = Invoke-McpRequest -Method "tools/list" -Params @{} -Id 3 -Role "anonymous" +$customToolCount = ([regex]::Matches($response, '"name":"(get_books|get_book|insert_book|count_books)"')).Count +Write-Host "Custom tools found: $customToolCount / 4" -ForegroundColor $(if ($customToolCount -eq 4) { "Green" } else { "Red" }) +Write-Host "" + +# Test 4: Multiple rapid requests +Write-Host "Test 4: Multiple rapid requests (concurrency test)" -ForegroundColor Yellow +Write-Host "Expected: All requests should complete successfully" -ForegroundColor Gray +$jobs = @() +1..5 | ForEach-Object { + $jobs += Start-Job -ScriptBlock { + param($baseUrl, $headers, $i) + + # Bypass SSL in job context + add-type @" + using System.Net; + using System.Security.Cryptography.X509Certificates; + public class TrustAllCertsPolicy3 : ICertificatePolicy { + public bool CheckValidationResult( + ServicePoint svcPoint, X509Certificate certificate, + WebRequest request, int certificateProblem) { + return true; + } + } +"@ + [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy3 + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + $body = @{ + jsonrpc = "2.0" + method = "tools/call" + params = @{ + name = "count_books" + } + id = $i + } | ConvertTo-Json -Depth 10 + + try { + $response = Invoke-WebRequest -Uri $baseUrl -Method Post -Headers $headers -Body $body -UseBasicParsing + return @{ Success = $true; Id = $i; Response = $response.Content } + } + catch { + return @{ Success = $false; Id = $i; Error = $_.Exception.Message } + } + } -ArgumentList $baseUrl, $headers, $_ +} + +$results = $jobs | Wait-Job | Receive-Job +$successCount = ($results | Where-Object { $_.Success }).Count +Write-Host "Successful requests: $successCount / 5" -ForegroundColor $(if ($successCount -eq 5) { "Green" } else { "Yellow" }) +$jobs | Remove-Job +Write-Host "" + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Permission Tests Complete" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan From 7f7b431b0d2eddda65c138eee7bd76060cc1ea8d Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Mon, 12 Jan 2026 15:26:00 +0530 Subject: [PATCH 11/15] Refactorings --- src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs | 1 - .../{BuiltInTools => Core}/DynamicCustomTool.cs | 4 ++-- .../Core/McpServiceCollectionExtensions.cs | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) rename src/Azure.DataApiBuilder.Mcp/{BuiltInTools => Core}/DynamicCustomTool.cs (99%) diff --git a/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs b/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs index 569155a796..b6fee9733b 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using Azure.DataApiBuilder.Config.ObjectModel; -using Azure.DataApiBuilder.Mcp.BuiltInTools; using Azure.DataApiBuilder.Mcp.Model; using Microsoft.Extensions.Logging; diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DynamicCustomTool.cs b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs similarity index 99% rename from src/Azure.DataApiBuilder.Mcp/BuiltInTools/DynamicCustomTool.cs rename to src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs index 4c24209cc2..3d8aa386c8 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DynamicCustomTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs @@ -23,7 +23,7 @@ using ModelContextProtocol.Protocol; using static Azure.DataApiBuilder.Mcp.Model.McpEnums; -namespace Azure.DataApiBuilder.Mcp.BuiltInTools +namespace Azure.DataApiBuilder.Mcp.Core { /// /// Dynamic custom MCP tool generated from stored procedure entity configuration. @@ -96,7 +96,7 @@ public async Task ExecuteAsync( RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); RuntimeConfig config = runtimeConfigProvider.GetConfig(); - // 2) Parse arguments - for POC, accept simple object format + // 2) Parse arguments from the request Dictionary parameters = new(); if (arguments != null) { diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs index c1e7045a5f..bc87602da9 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs @@ -4,7 +4,6 @@ using System.Reflection; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; -using Azure.DataApiBuilder.Mcp.BuiltInTools; using Azure.DataApiBuilder.Mcp.Model; using Microsoft.Extensions.DependencyInjection; From c2c90e7fa680d7f4fbc7b6a49bff99fd1fc82724 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Mon, 12 Jan 2026 16:08:11 +0530 Subject: [PATCH 12/15] Remove temp test artifacts --- TEST-RESULTS.md | 180 ----------------------------------- test-custom-tools.ps1 | 214 ------------------------------------------ test-descriptions.ps1 | 83 ---------------- test-edge-cases.ps1 | 178 ----------------------------------- test-permissions.ps1 | 156 ------------------------------ 5 files changed, 811 deletions(-) delete mode 100644 TEST-RESULTS.md delete mode 100644 test-custom-tools.ps1 delete mode 100644 test-descriptions.ps1 delete mode 100644 test-edge-cases.ps1 delete mode 100644 test-permissions.ps1 diff --git a/TEST-RESULTS.md b/TEST-RESULTS.md deleted file mode 100644 index 04d3be5551..0000000000 --- a/TEST-RESULTS.md +++ /dev/null @@ -1,180 +0,0 @@ -# Custom MCP Tools - Test Results Summary - -**Date:** December 18, 2025 -**Branch:** Usr/sogh/entity-level-mcp-config -**POC Status:** ✅ PASSED - -## Overview -Comprehensive testing of the dynamic custom MCP tools POC that elevates stored procedures to dedicated MCP tools based on configuration. - -## Test Configuration -- **Custom Tools Enabled:** 4 stored procedures - - `get_books` - Parameterless query - - `get_book` - Query with @id parameter - - `insert_book` - Mutation with @title and @publisher_id parameters - - `count_books` - Parameterless aggregation - -## Test Results - -### ✅ Core Functionality (12/12 Passed) - -1. **Tool Discovery** - - ✅ All 4 custom tools appear in `tools/list` - - ✅ Tool names correctly converted to lowercase_underscore format - - ✅ Input schemas generated with correct parameter types - -2. **Parameterless Execution** - - ✅ `get_books` returns complete book list (30 books) - - ✅ `count_books` returns correct count (24 books after inserts) - -3. **Parameterized Execution** - - ✅ `get_book` with valid id (id=1) returns correct book - - ✅ `get_book` with non-existent id (id=999999) returns empty array gracefully - -4. **Parameter Validation** - - ✅ Missing required parameter (@id) produces proper SQL error - - ✅ Extra unexpected parameters ignored gracefully - -5. **Data Mutation** - - ✅ `insert_book` successfully inserts new records - - ✅ Partial parameters use default values from config - - ✅ Book count incremented correctly after inserts (22 → 24) - -6. **Error Handling** - - ✅ Invalid publisher_id rejected with FK constraint error - - ✅ Non-existent tool name returns proper JSON-RPC error - - ✅ Invalid parameter types produce appropriate SQL errors - -### ✅ Edge Cases (10/10 Passed) - -1. **SQL Injection Protection** - - ✅ Input: `id = "1; DROP TABLE books; --"` - - ✅ Result: Parameterized safely, conversion error returned - -2. **Large Data Handling** - - ✅ 10,000 character string inserted successfully - - ✅ No truncation or crashes - -3. **Special Characters** - - ✅ Quotes, double quotes, angle brackets handled correctly - - ✅ Empty strings accepted and stored - -4. **Type Handling** - - ✅ String value for integer parameter produces type error - - ✅ Negative IDs handled gracefully (returns empty) - - ✅ Int32.MaxValue processed correctly - -5. **Tool Name Case Sensitivity** - - ✅ `GET_BOOKS` (uppercase) rejected with "Unknown tool" error - - ✅ Tool names are case-sensitive as expected - -### ✅ Permission & Authorization (4/4 Passed) - -1. **Anonymous Access** - - ✅ Anonymous role can execute `get_books` - - ✅ Anonymous role can list tools - -2. **Authenticated Access** - - ✅ Authenticated role can execute `insert_book` - -3. **Concurrency** - - ✅ 5 parallel requests all succeeded - - ✅ No race conditions or deadlocks - -## Key Findings - -### ✅ Strengths -1. **SQL Injection Protection:** Parameterized queries work correctly -2. **Error Handling:** Proper SQL and validation errors returned -3. **Data Integrity:** Foreign key constraints enforced -4. **Concurrency:** Multiple simultaneous requests handled well -5. **Special Characters:** Quotes, brackets, empty strings all work -6. **Large Data:** 10K character strings processed successfully - -### ⚠️ Areas for Enhancement (Future PRs) - -1. **Parameter Schema Generation:** - - `get_book` shows empty schema even though it requires @id parameter - - Only parameters with default values in config appear in schema - - Need to extract parameter info from stored procedure metadata - -2. **Type Information:** - - All parameters currently shown as "string" type in schema - - Should reflect actual SQL parameter types (int, varchar, etc.) - -3. **Description Quality:** - - Generic descriptions: "Execute {EntityName} stored procedure" - - Could be enhanced with stored procedure comments or annotations - -4. **Required vs Optional:** - - Schema doesn't indicate which parameters are required - - All parameters treated as optional if they have defaults - -## Response Format Examples - -### Successful Execution -```json -{ - "entity": "GetBooks", - "message": "Execution successful", - "value": { - "value": [ - { "id": 1, "title": "Awesome book", "publisher_id": 1234 } - ] - }, - "status": "success" -} -``` - -### Error Response -```json -{ - "toolName": "get_book", - "status": "error", - "error": { - "type": "ExecutionError", - "message": "Procedure or function 'get_book_by_id' expects parameter '@id', which was not supplied." - } -} -``` - -### JSON-RPC Error -```json -{ - "error": { - "code": -32603, - "message": "Unknown tool: 'non_existent_tool'" - }, - "id": 10, - "jsonrpc": "2.0" -} -``` - -## Performance Notes -- Tool registration occurs at startup (single-time cost) -- No noticeable latency in tool listing -- Execution performance matches regular stored procedure calls -- Concurrent requests handled without degradation - -## Recommendations for PR 1 - -1. **Keep Current POC Approach:** - - Simple factory pattern works well - - Delegation to existing execute_entity logic is solid - - Lowercase_underscore naming convention appropriate - -2. **Add Unit Tests For:** - - Tool name conversion (GetBooks → get_books) - - Schema generation for various parameter types - - Error scenarios (missing entity, invalid config) - - Collision detection (duplicate tool names) - -3. **Document Limitations:** - - Parameter info limited to config defaults (not extracted from DB) - - No hot-reload support yet (requires restart) - - No custom descriptions (uses generic template) - -## Conclusion -The POC successfully demonstrates the core concept of dynamic custom tool generation. All functional requirements are met, with proper error handling, security, and performance. Ready to proceed with structured PR implementation. - -**Next Step:** Begin PR 1 - Core Infrastructure with comprehensive unit tests and enhanced parameter schema generation. diff --git a/test-custom-tools.ps1 b/test-custom-tools.ps1 deleted file mode 100644 index 5dfd9a1b61..0000000000 --- a/test-custom-tools.ps1 +++ /dev/null @@ -1,214 +0,0 @@ -# Test script for MCP Custom Tools -# Tests various scenarios and edge cases - -# Bypass SSL certificate validation for local testing -add-type @" - using System.Net; - using System.Security.Cryptography.X509Certificates; - public class TrustAllCertsPolicy : ICertificatePolicy { - public bool CheckValidationResult( - ServicePoint srvPoint, X509Certificate certificate, - WebRequest request, int certificateProblem) { - return true; - } - } -"@ -[System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy - -$baseUrl = "https://localhost:5001/mcp" -$headers = @{ - "Content-Type" = "application/json" - "Accept" = "application/json, text/event-stream" -} - -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "MCP Custom Tools - Test Suite" -ForegroundColor Cyan -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "" - -# Helper function to make MCP requests -function Invoke-McpRequest { - param( - [string]$Method, - [object]$Params, - [int]$Id = 1 - ) - - $body = @{ - jsonrpc = "2.0" - method = $Method - params = $Params - id = $Id - } | ConvertTo-Json -Depth 10 - - try { - $response = Invoke-RestMethod -Uri $baseUrl -Method Post -Headers $headers -Body $body - return $response - } - catch { - Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red - if ($_.ErrorDetails.Message) { - Write-Host "Details: $($_.ErrorDetails.Message)" -ForegroundColor Red - } - return $null - } -} - -# Test 1: List all tools -Write-Host "Test 1: List all tools" -ForegroundColor Yellow -Write-Host "Expected: Should see get_books, get_book, insert_book, count_books in the list" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/list" -Params @{} -if ($response) { - $customTools = $response.result.tools | Where-Object { $_.name -match "^(get_books|get_book|insert_book|count_books)$" } - Write-Host "Custom tools found: $($customTools.Count)" -ForegroundColor Green - $customTools | ForEach-Object { - Write-Host " - $($_.name): $($_.description)" -ForegroundColor White - Write-Host " Input Schema: $($_.inputSchema | ConvertTo-Json -Compress)" -ForegroundColor Gray - } -} -Write-Host "" - -# Test 2: Call get_books (no parameters) -Write-Host "Test 2: Call get_books (no parameters)" -ForegroundColor Yellow -Write-Host "Expected: Should return list of books" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ name = "get_books" } -Id 2 -if ($response) { - Write-Host "Success! Response:" -ForegroundColor Green - Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White -} -Write-Host "" - -# Test 3: Call count_books (no parameters) -Write-Host "Test 3: Call count_books (no parameters)" -ForegroundColor Yellow -Write-Host "Expected: Should return total count of books" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ name = "count_books" } -Id 3 -if ($response) { - Write-Host "Success! Response:" -ForegroundColor Green - Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White -} -Write-Host "" - -# Test 4: Call get_book with parameter (id=1) -Write-Host "Test 4: Call get_book with parameter (id=1)" -ForegroundColor Yellow -Write-Host "Expected: Should return book with id=1" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "get_book" - arguments = @{ id = 1 } -} -Id 4 -if ($response) { - Write-Host "Success! Response:" -ForegroundColor Green - Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White -} -Write-Host "" - -# Test 5: Call get_book with non-existent id -Write-Host "Test 5: Call get_book with non-existent id (id=999999)" -ForegroundColor Yellow -Write-Host "Expected: Should return empty result or handle gracefully" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "get_book" - arguments = @{ id = 999999 } -} -Id 5 -if ($response) { - Write-Host "Response:" -ForegroundColor Green - Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White -} -Write-Host "" - -# Test 6: Call get_book without required parameter -Write-Host "Test 6: Call get_book without required parameter" -ForegroundColor Yellow -Write-Host "Expected: Should fail with parameter error" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "get_book" -} -Id 6 -if ($response) { - Write-Host "Response:" -ForegroundColor Green - Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White -} -Write-Host "" - -# Test 7: Call insert_book with parameters -Write-Host "Test 7: Call insert_book with parameters" -ForegroundColor Yellow -Write-Host "Expected: Should insert a new book" -ForegroundColor Gray -$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "insert_book" - arguments = @{ - title = "MCP Test Book $timestamp" - publisher_id = "1234" - } -} -Id 7 -if ($response) { - Write-Host "Success! Response:" -ForegroundColor Green - Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White -} -Write-Host "" - -# Test 8: Call insert_book with only title (missing publisher_id) -Write-Host "Test 8: Call insert_book with only title (missing publisher_id)" -ForegroundColor Yellow -Write-Host "Expected: Should use default value from config (1234) or fail" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "insert_book" - arguments = @{ - title = "Test Book Missing Publisher" - } -} -Id 8 -if ($response) { - Write-Host "Response:" -ForegroundColor Green - Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White -} -Write-Host "" - -# Test 9: Call insert_book with invalid publisher_id -Write-Host "Test 9: Call insert_book with invalid publisher_id" -ForegroundColor Yellow -Write-Host "Expected: Should fail with foreign key constraint error" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "insert_book" - arguments = @{ - title = "Test Book Invalid Publisher" - publisher_id = "99999" - } -} -Id 9 -if ($response) { - Write-Host "Response:" -ForegroundColor Green - Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White -} -Write-Host "" - -# Test 10: Call non-existent custom tool -Write-Host "Test 10: Call non-existent custom tool" -ForegroundColor Yellow -Write-Host "Expected: Should fail with tool not found error" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "non_existent_tool" -} -Id 10 -if ($response) { - Write-Host "Response:" -ForegroundColor Green - Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White -} -Write-Host "" - -# Test 11: Verify count after inserts -Write-Host "Test 11: Verify count after inserts" -ForegroundColor Yellow -Write-Host "Expected: Should show updated count" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ name = "count_books" } -Id 11 -if ($response) { - Write-Host "Success! Response:" -ForegroundColor Green - Write-Host ($response | ConvertTo-Json -Depth 5) -ForegroundColor White -} -Write-Host "" - -# Test 12: Call get_books again to see new books -Write-Host "Test 12: Call get_books to see newly inserted books" -ForegroundColor Yellow -Write-Host "Expected: Should include newly inserted books" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ name = "get_books" } -Id 12 -if ($response) { - Write-Host "Success! Response (first 5 books):" -ForegroundColor Green - $books = $response.result.content[0].text | ConvertFrom-Json - $books | Select-Object -First 5 | ForEach-Object { - Write-Host " Book: $($_.title) (ID: $($_.id), Publisher: $($_.publisher_id))" -ForegroundColor White - } -} -Write-Host "" - -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "Test Suite Complete" -ForegroundColor Cyan -Write-Host "========================================" -ForegroundColor Cyan diff --git a/test-descriptions.ps1 b/test-descriptions.ps1 deleted file mode 100644 index fa3017c4b4..0000000000 --- a/test-descriptions.ps1 +++ /dev/null @@ -1,83 +0,0 @@ -# Test enhanced custom tools with descriptions -# Bypass SSL validation -if (-not ([System.Management.Automation.PSTypeName]'TrustAllCertsPolicy4').Type) { - add-type @" - using System.Net; - using System.Security.Cryptography.X509Certificates; - public class TrustAllCertsPolicy4 : ICertificatePolicy { - public bool CheckValidationResult( - ServicePoint svcPoint, X509Certificate certificate, - WebRequest request, int certificateProblem) { - return true; - } - } -"@ - [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy4 -} -[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - -$baseUrl = "https://localhost:5001/mcp" -$headers = @{ - "Content-Type" = "application/json" - "Accept" = "application/json, text/event-stream" -} - -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "Enhanced Custom Tools - Description Test" -ForegroundColor Cyan -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "" - -# Test 1: List tools and check descriptions -Write-Host "Test 1: List tools and verify descriptions" -ForegroundColor Yellow -$body = @{ - jsonrpc = "2.0" - method = "tools/list" - params = @{} - id = 1 -} | ConvertTo-Json - -try { - $response = Invoke-WebRequest -Uri $baseUrl -Method Post -Headers $headers -Body $body -UseBasicParsing - $content = $response.Content - - # Parse SSE format - if ($content -match 'data: (.+)') { - $jsonData = $matches[1] - $data = $jsonData | ConvertFrom-Json - - if ($data.result.tools) { - $customTools = $data.result.tools | Where-Object { $_.name -in @('get_books', 'get_book', 'insert_book', 'count_books') } - - Write-Host "" - Write-Host "Custom Tools Found:" -ForegroundColor Green - foreach ($tool in $customTools) { - Write-Host "" - Write-Host " Tool: $($tool.name)" -ForegroundColor Cyan - Write-Host " Description: $($tool.description)" -ForegroundColor White - - # Check for parameters - if ($tool.inputSchema.properties) { - $propNames = $tool.inputSchema.properties.PSObject.Properties.Name - if ($propNames.Count -gt 0) { - Write-Host " Parameters:" -ForegroundColor Yellow - foreach ($paramName in $propNames) { - $paramDesc = $tool.inputSchema.properties.$paramName.description - Write-Host " - $paramName : $paramDesc" -ForegroundColor Gray - } - } - } - } - - Write-Host "" - Write-Host "All custom tools have descriptions!" -ForegroundColor Green - } - } -} -catch { - Write-Host "✗ Error: $($_.Exception.Message)" -ForegroundColor Red -} - -Write-Host "" -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "Description Test Complete" -ForegroundColor Cyan -Write-Host "========================================" -ForegroundColor Cyan diff --git a/test-edge-cases.ps1 b/test-edge-cases.ps1 deleted file mode 100644 index 31ac54d259..0000000000 --- a/test-edge-cases.ps1 +++ /dev/null @@ -1,178 +0,0 @@ -# Edge case tests for custom MCP tools -# Bypass SSL validation -add-type @" - using System.Net; - using System.Security.Cryptography.X509Certificates; - public class TrustAllCertsPolicy : ICertificatePolicy { - public bool CheckValidationResult( - ServicePoint svcPoint, X509Certificate certificate, - WebRequest request, int certificateProblem) { - return true; - } - } -"@ -[System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy -[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - -$baseUrl = "https://localhost:5001/mcp" -$headers = @{ - "Content-Type" = "application/json" - "Accept" = "application/json, text/event-stream" -} - -function Invoke-McpRequest { - param( - [string]$Method, - [hashtable]$Params, - [int]$Id - ) - - $body = @{ - jsonrpc = "2.0" - method = $Method - params = $Params - id = $Id - } | ConvertTo-Json -Depth 10 - - try { - $response = Invoke-WebRequest -Uri $baseUrl -Method Post -Headers $headers -Body $body -UseBasicParsing - return $response.Content - } - catch { - return $_.Exception.Message - } -} - -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "Custom MCP Tools - Edge Case Tests" -ForegroundColor Cyan -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "" - -# Edge Case 1: SQL Injection attempt -Write-Host "Edge Case 1: SQL Injection attempt in parameters" -ForegroundColor Yellow -Write-Host "Expected: Should be safely parameterized, no SQL injection" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "get_book" - arguments = @{ - id = "1; DROP TABLE books; --" - } -} -Id 100 -Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green -Write-Host "" - -# Edge Case 2: Very large parameter value -Write-Host "Edge Case 2: Very large string parameter" -ForegroundColor Yellow -Write-Host "Expected: Should handle or reject gracefully" -ForegroundColor Gray -$largeString = "A" * 10000 -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "insert_book" - arguments = @{ - title = $largeString - publisher_id = "1234" - } -} -Id 101 -Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green -Write-Host "" - -# Edge Case 3: Special characters in parameters -Write-Host "Edge Case 3: Special characters in title" -ForegroundColor Yellow -Write-Host "Expected: Should handle special characters correctly" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "insert_book" - arguments = @{ - title = "Test Book with 'quotes' and `"double quotes`" and " - publisher_id = "1234" - } -} -Id 102 -Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green -Write-Host "" - -# Edge Case 4: Null/empty parameters -Write-Host "Edge Case 4: Empty string for title" -ForegroundColor Yellow -Write-Host "Expected: Should handle empty string" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "insert_book" - arguments = @{ - title = "" - publisher_id = "1234" - } -} -Id 103 -Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green -Write-Host "" - -# Edge Case 5: Wrong parameter type -Write-Host "Edge Case 5: String value for integer parameter" -ForegroundColor Yellow -Write-Host "Expected: Should convert or reject with type error" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "get_book" - arguments = @{ - id = "not_a_number" - } -} -Id 104 -Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green -Write-Host "" - -# Edge Case 6: Extra unexpected parameters -Write-Host "Edge Case 6: Extra parameters not in schema" -ForegroundColor Yellow -Write-Host "Expected: Should ignore extra parameters or fail gracefully" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "get_books" - arguments = @{ - unexpected_param = "value" - another_param = 123 - } -} -Id 105 -Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green -Write-Host "" - -# Edge Case 7: Unicode characters -Write-Host "Edge Case 7: Unicode characters in title" -ForegroundColor Yellow -Write-Host "Expected: Should handle Unicode correctly" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "insert_book" - arguments = @{ - title = "Test Unicode Book" - publisher_id = "1234" - } -} -Id 106 -Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green -Write-Host "" - -# Edge Case 8: Negative ID -Write-Host "Edge Case 8: Negative ID value" -ForegroundColor Yellow -Write-Host "Expected: Should return empty result" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "get_book" - arguments = @{ - id = -1 - } -} -Id 107 -Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green -Write-Host "" - -# Edge Case 9: Very large ID -Write-Host "Edge Case 9: Very large ID value (Int32.MaxValue)" -ForegroundColor Yellow -Write-Host "Expected: Should handle large integers" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "get_book" - arguments = @{ - id = 2147483647 - } -} -Id 108 -Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green -Write-Host "" - -# Edge Case 10: Case sensitivity in tool names -Write-Host "Edge Case 10: Wrong case in tool name" -ForegroundColor Yellow -Write-Host "Expected: Should fail - tool names are case sensitive" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "GET_BOOKS" - arguments = @{} -} -Id 109 -Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..." -ForegroundColor Green -Write-Host "" - -Write-Host "" -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "Edge Case Tests Complete" -ForegroundColor Cyan -Write-Host "========================================" -ForegroundColor Cyan diff --git a/test-permissions.ps1 b/test-permissions.ps1 deleted file mode 100644 index 41a1d555a0..0000000000 --- a/test-permissions.ps1 +++ /dev/null @@ -1,156 +0,0 @@ -# Permission tests for custom MCP tools -# Test with different authentication roles - -$baseUrl = "https://localhost:5001/mcp" -$headers = @{ - "Content-Type" = "application/json" - "Accept" = "application/json, text/event-stream" -} - -# Bypass SSL validation -if (-not ([System.Management.Automation.PSTypeName]'TrustAllCertsPolicy2').Type) { - add-type @" - using System.Net; - using System.Security.Cryptography.X509Certificates; - public class TrustAllCertsPolicy2 : ICertificatePolicy { - public bool CheckValidationResult( - ServicePoint svcPoint, X509Certificate certificate, - WebRequest request, int certificateProblem) { - return true; - } - } -"@ - [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy2 -} -[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - -function Invoke-McpRequest { - param( - [string]$Method, - [hashtable]$Params, - [int]$Id, - [string]$Role = "anonymous" - ) - - $requestHeaders = $headers.Clone() - if ($Role -ne "anonymous") { - $requestHeaders["X-MS-CLIENT-PRINCIPAL"] = ConvertTo-Json @{ - userId = "test-user-123" - userRoles = @($Role) - claims = @() - identityProvider = "staticwebapps" - } -Compress - } - - $body = @{ - jsonrpc = "2.0" - method = $Method - params = $Params - id = $Id - } | ConvertTo-Json -Depth 10 - - try { - $response = Invoke-WebRequest -Uri $baseUrl -Method Post -Headers $requestHeaders -Body $body -UseBasicParsing - return $response.Content - } - catch { - return "ERROR: $($_.Exception.Message)" - } -} - -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "Custom MCP Tools - Permission Tests" -ForegroundColor Cyan -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "" - -# Test 1: Anonymous role (default in config) -Write-Host "Test 1: Call get_books as anonymous user" -ForegroundColor Yellow -Write-Host "Expected: Should succeed - anonymous has execute permission" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "get_books" -} -Id 1 -Role "anonymous" -if ($response -like "*Execution successful*") { - Write-Host "✓ SUCCESS" -ForegroundColor Green -} else { - Write-Host "✗ FAILED" -ForegroundColor Red - Write-Host "Response: $($response.Substring(0, [Math]::Min(300, $response.Length)))" -ForegroundColor Red -} -Write-Host "" - -# Test 2: Authenticated role -Write-Host "Test 2: Call insert_book as authenticated user" -ForegroundColor Yellow -Write-Host "Expected: Should succeed - authenticated has execute permission" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/call" -Params @{ - name = "insert_book" - arguments = @{ - title = "Permission Test Book" - publisher_id = "1234" - } -} -Id 2 -Role "authenticated" -if ($response -like "*Execution successful*") { - Write-Host "✓ SUCCESS" -ForegroundColor Green -} else { - Write-Host "✗ FAILED" -ForegroundColor Red - Write-Host "Response: $($response.Substring(0, [Math]::Min(300, $response.Length)))" -ForegroundColor Red -} -Write-Host "" - -# Test 3: Check tools list is accessible anonymously -Write-Host "Test 3: List tools as anonymous" -ForegroundColor Yellow -Write-Host "Expected: Should see all custom tools" -ForegroundColor Gray -$response = Invoke-McpRequest -Method "tools/list" -Params @{} -Id 3 -Role "anonymous" -$customToolCount = ([regex]::Matches($response, '"name":"(get_books|get_book|insert_book|count_books)"')).Count -Write-Host "Custom tools found: $customToolCount / 4" -ForegroundColor $(if ($customToolCount -eq 4) { "Green" } else { "Red" }) -Write-Host "" - -# Test 4: Multiple rapid requests -Write-Host "Test 4: Multiple rapid requests (concurrency test)" -ForegroundColor Yellow -Write-Host "Expected: All requests should complete successfully" -ForegroundColor Gray -$jobs = @() -1..5 | ForEach-Object { - $jobs += Start-Job -ScriptBlock { - param($baseUrl, $headers, $i) - - # Bypass SSL in job context - add-type @" - using System.Net; - using System.Security.Cryptography.X509Certificates; - public class TrustAllCertsPolicy3 : ICertificatePolicy { - public bool CheckValidationResult( - ServicePoint svcPoint, X509Certificate certificate, - WebRequest request, int certificateProblem) { - return true; - } - } -"@ - [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy3 - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - - $body = @{ - jsonrpc = "2.0" - method = "tools/call" - params = @{ - name = "count_books" - } - id = $i - } | ConvertTo-Json -Depth 10 - - try { - $response = Invoke-WebRequest -Uri $baseUrl -Method Post -Headers $headers -Body $body -UseBasicParsing - return @{ Success = $true; Id = $i; Response = $response.Content } - } - catch { - return @{ Success = $false; Id = $i; Error = $_.Exception.Message } - } - } -ArgumentList $baseUrl, $headers, $_ -} - -$results = $jobs | Wait-Job | Receive-Job -$successCount = ($results | Where-Object { $_.Success }).Count -Write-Host "Successful requests: $successCount / 5" -ForegroundColor $(if ($successCount -eq 5) { "Green" } else { "Yellow" }) -$jobs | Remove-Job -Write-Host "" - -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "Permission Tests Complete" -ForegroundColor Cyan -Write-Host "========================================" -ForegroundColor Cyan From 7dc5c65dc8ad89bcbcf915abcf2c4e7f9e9f70d6 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Mon, 12 Jan 2026 16:36:49 +0530 Subject: [PATCH 13/15] Remove temp/duplicate test snapshot files --- ...EntityWithMcpDmlToolsDisabled.verified.txt | 59 ----------------- ...eEntityWithMcpDmlToolsEnabled.verified.txt | 59 ----------------- ...EntityWithMcpDmlToolsDisabled.verified.txt | 63 ------------------- ...eEntityWithMcpDmlToolsEnabled.verified.txt | 63 ------------------- 4 files changed, 244 deletions(-) delete mode 100644 src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsDisabled.verified.txt delete mode 100644 src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsEnabled.verified.txt delete mode 100644 src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsDisabled.verified.txt delete mode 100644 src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsEnabled.verified.txt diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsDisabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsDisabled.verified.txt deleted file mode 100644 index 384fdbb80c..0000000000 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsDisabled.verified.txt +++ /dev/null @@ -1,59 +0,0 @@ -{ - DataSource: { - DatabaseType: MSSQL - }, - Runtime: { - Rest: { - Enabled: true, - Path: /api, - RequestBodyStrict: true - }, - GraphQL: { - Enabled: true, - Path: /graphql, - AllowIntrospection: true - }, - Host: { - Cors: { - AllowCredentials: false - }, - Authentication: { - Provider: StaticWebApps - } - } - }, - Entities: [ - { - Author: { - Source: { - Object: authors, - Type: Table - }, - GraphQL: { - Singular: Author, - Plural: Authors, - Enabled: true - }, - Rest: { - Enabled: true - }, - Permissions: [ - { - Role: anonymous, - Actions: [ - { - Action: * - } - ] - } - ], - Mcp: { - CustomToolEnabled: false, - DmlToolEnabled: false, - UserProvidedCustomToolEnabled: false, - UserProvidedDmlToolsEnabled: true - } - } - } - ] -} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsEnabled.verified.txt deleted file mode 100644 index e08eb2e4b3..0000000000 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlToolsEnabled.verified.txt +++ /dev/null @@ -1,59 +0,0 @@ -{ - DataSource: { - DatabaseType: MSSQL - }, - Runtime: { - Rest: { - Enabled: true, - Path: /api, - RequestBodyStrict: true - }, - GraphQL: { - Enabled: true, - Path: /graphql, - AllowIntrospection: true - }, - Host: { - Cors: { - AllowCredentials: false - }, - Authentication: { - Provider: StaticWebApps - } - } - }, - Entities: [ - { - Book: { - Source: { - Object: books, - Type: Table - }, - GraphQL: { - Singular: Book, - Plural: Books, - Enabled: true - }, - Rest: { - Enabled: true - }, - Permissions: [ - { - Role: anonymous, - Actions: [ - { - Action: * - } - ] - } - ], - Mcp: { - CustomToolEnabled: false, - DmlToolEnabled: true, - UserProvidedCustomToolEnabled: false, - UserProvidedDmlToolsEnabled: true - } - } - } - ] -} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsDisabled.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsDisabled.verified.txt deleted file mode 100644 index f22dee731f..0000000000 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsDisabled.verified.txt +++ /dev/null @@ -1,63 +0,0 @@ -{ - DataSource: { - DatabaseType: MSSQL - }, - Runtime: { - Rest: { - Enabled: true, - Path: /, - RequestBodyStrict: true - }, - GraphQL: { - Enabled: true, - Path: /graphql, - AllowIntrospection: true - }, - Host: { - Cors: { - AllowCredentials: false - }, - Authentication: { - Provider: StaticWebApps, - Jwt: { - Audience: , - Issuer: - } - } - } - }, - Entities: [ - { - MyEntity: { - Source: { - Object: MyTable, - Type: Table - }, - GraphQL: { - Singular: MyEntity, - Plural: MyEntities, - Enabled: true - }, - Rest: { - Enabled: true - }, - Permissions: [ - { - Role: anonymous, - Actions: [ - { - Action: * - } - ] - } - ], - Mcp: { - CustomToolEnabled: false, - DmlToolEnabled: false, - UserProvidedCustomToolEnabled: false, - UserProvidedDmlToolsEnabled: true - } - } - } - ] -} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsEnabled.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsEnabled.verified.txt deleted file mode 100644 index 9c3eca020a..0000000000 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlToolsEnabled.verified.txt +++ /dev/null @@ -1,63 +0,0 @@ -{ - DataSource: { - DatabaseType: MSSQL - }, - Runtime: { - Rest: { - Enabled: true, - Path: /, - RequestBodyStrict: true - }, - GraphQL: { - Enabled: true, - Path: /graphql, - AllowIntrospection: true - }, - Host: { - Cors: { - AllowCredentials: false - }, - Authentication: { - Provider: StaticWebApps, - Jwt: { - Audience: , - Issuer: - } - } - } - }, - Entities: [ - { - MyEntity: { - Source: { - Object: MyTable, - Type: Table - }, - GraphQL: { - Singular: MyEntity, - Plural: MyEntities, - Enabled: true - }, - Rest: { - Enabled: true - }, - Permissions: [ - { - Role: anonymous, - Actions: [ - { - Action: * - } - ] - } - ], - Mcp: { - CustomToolEnabled: false, - DmlToolEnabled: true, - UserProvidedCustomToolEnabled: false, - UserProvidedDmlToolsEnabled: true - } - } - } - ] -} \ No newline at end of file From b9fb4d0fc737dcfb8392e34b3b36b0f38dfd505c Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Mon, 12 Jan 2026 16:41:53 +0530 Subject: [PATCH 14/15] Revert temp changes from dab config --- src/Service.Tests/dab-config.MsSql.json | 50 +++++-------------------- 1 file changed, 10 insertions(+), 40 deletions(-) diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index c6a1181765..e7f73702ba 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -1,8 +1,8 @@ -{ +{ "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json", "data-source": { "database-type": "mssql", - "connection-string": "Server=tcp:localhost,1433;Persist Security Info=False;Initial Catalog=Library;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=30;", + "connection-string": "Server=tcp:127.0.0.1,1433;Persist Security Info=False;User ID=sa;Password=REPLACEME;MultipleActiveResultSets=False;Connection Timeout=5;", "options": { "set-session-context": true } @@ -2855,7 +2855,6 @@ } }, "GetBooks": { - "description": "Retrieves all books from the library database with their titles and publisher information", "source": { "object": "get_books", "type": "stored-procedure" @@ -2894,17 +2893,9 @@ ] }, "GetBook": { - "description": "Retrieves a specific book by its unique identifier", "source": { "object": "get_book_by_id", - "type": "stored-procedure", - "parameters": [ - { - "name": "id", - "required": true, - "description": "The unique identifier of the book to retrieve" - } - ] + "type": "stored-procedure" }, "graphql": { "enabled": false, @@ -2937,10 +2928,7 @@ } ] } - ], - "mcp": { - "custom-tool": true - } + ] }, "GetPublisher": { "source": { @@ -2981,24 +2969,13 @@ ] }, "InsertBook": { - "description": "Adds a new book to the library database with the specified title and publisher", "source": { "object": "insert_book", "type": "stored-procedure", - "parameters": [ - { - "name": "title", - "required": true, - "default": "randomX", - "description": "The title of the book to insert" - }, - { - "name": "publisher_id", - "required": false, - "default": "1234", - "description": "The unique identifier of the publisher" - } - ] + "parameters": { + "title": "randomX", + "publisher_id": 1234 + } }, "graphql": { "enabled": true, @@ -3031,13 +3008,9 @@ } ] } - ], - "mcp": { - "custom-tool": true - } + ] }, "CountBooks": { - "description": "Returns the total count of books in the library database", "source": { "object": "count_books", "type": "stored-procedure" @@ -3073,10 +3046,7 @@ } ] } - ], - "mcp": { - "custom-tool": true - } + ] }, "DeleteLastInsertedBook": { "source": { From 630442184f154c50b87c5b66de8837eaeb0a1c93 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Mon, 12 Jan 2026 21:42:58 +0530 Subject: [PATCH 15/15] Formatting --- src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs | 2 +- src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs b/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs index b6fee9733b..c22ebe79b1 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs @@ -40,7 +40,7 @@ public static IEnumerable CreateCustomTools(RuntimeConfig config, ILog { DynamicCustomTool tool = new(entityName, entity); customToolCount++; - + logger?.LogInformation( "Created custom MCP tool '{ToolName}' for stored procedure entity '{EntityName}'", tool.GetToolMetadata().Name, diff --git a/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs index 3d8aa386c8..3ed596c218 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs @@ -262,7 +262,7 @@ private JsonElement BuildInputSchema() if (_entity.Source.Parameters != null && _entity.Source.Parameters.Any()) { var properties = (Dictionary)schema["properties"]; - + foreach (var param in _entity.Source.Parameters) { properties[param.Name] = new Dictionary