From e23e3a5780af3d84dd023fe913c1613328a86273 Mon Sep 17 00:00:00 2001 From: Souvik Ghosh Date: Wed, 17 Dec 2025 09:37:19 +0530 Subject: [PATCH 1/3] Add entity-level MCP configuration support (#2989) This change allows entity-level MCP configuration to control which entities participate in MCP runtime tools, providing granular control over DML operations and custom tool exposure. - Closes on #2948 This change introduces an optional mcp property at the entity level that controls participation in MCP's runtime tools. This is a prerequisite for custom tools support. The MCP property supports two formats: - **Boolean shorthand**: `"mcp": true` or `"mcp": false` - **Object format**: `{"dml-tools": boolean, "custom-tool": boolean}` Property Behavior: 1. Boolean Shorthand (`"mcp": true/false`) - `"mcp": true`: Enables DML tools only; custom tools remain disabled. - `"mcp": false`: Disables all MCP functionality for the entity. 2. Object Format `("mcp": { ... })` - `{ "dml-tools": true, "custom-tool": true }`: Enables both (valid only for stored procedures). - `{ "dml-tools": true, "custom-tool": false }`: DML only. - `{ "dml-tools": false, "custom-tool": true }`: Custom tool only (stored procedures). - `{ "dml-tools": false, "custom-tool": false }`: Fully disabled. Single-property cases: - `{"dml-tools": true}`: Enables DML only; auto-serializes to `"mcp": true`. - `{"custom-tool": true}`: Enables custom tool only; serializes as given. 3. No MCP Configuration in Entity (default) - `dml-tools` will still be enabled by default and no other change is behavior - [x] Unit Tests - [x] Integrations Tests - [x] CLI Command Testing Sample CLI commands: Add table with DML tools enabled `dab add Book --source books --permissions "anonymous:*" --mcp.dml-tools true` Add stored procedure with custom tool enabled `dab add GetBookById --source dbo.get_book_by_id --source.type stored-procedure --permissions "anonymous:execute" --mcp.custom-tool true` Add stored procedure with both properties `dab add UpdateBook --source dbo.update_book --source.type stored-procedure --permissions "anonymous:execute" --mcp.custom-tool true --mcp.dml-tools false` --- schemas/dab.draft.schema.json | 54 ++- src/Cli.Tests/AddEntityTests.cs | 254 +++++++++++ src/Cli.Tests/ModuleInitializer.cs | 4 + src/Cli.Tests/UpdateEntityTests.cs | 422 +++++++++++++++++- src/Cli/Commands/AddOptions.cs | 6 +- src/Cli/Commands/EntityOptions.cs | 12 +- src/Cli/Commands/UpdateOptions.cs | 6 +- src/Cli/ConfigGenerator.cs | 38 +- src/Cli/Utils.cs | 51 +++ .../EntityMcpOptionsConverterFactory.cs | 123 +++++ src/Config/ObjectModel/Entity.cs | 8 +- src/Config/ObjectModel/EntityMcpOptions.cs | 59 +++ src/Config/RuntimeConfigLoader.cs | 1 + .../EntityMcpConfigurationTests.cs | 389 ++++++++++++++++ src/Service.Tests/ModuleInitializer.cs | 4 + 15 files changed, 1422 insertions(+), 9 deletions(-) create mode 100644 src/Config/Converters/EntityMcpOptionsConverterFactory.cs create mode 100644 src/Config/ObjectModel/EntityMcpOptions.cs create mode 100644 src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index c8d4488526..e2fbfebac3 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", @@ -961,6 +961,31 @@ "default": 5 } } + }, + "mcp": { + "oneOf": [ + { + "type": "boolean", + "description": "Boolean shorthand: true enables dml-tools only (custom-tool remains false), false disables all MCP functionality." + }, + { + "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.Tests/AddEntityTests.cs b/src/Cli.Tests/AddEntityTests.cs index 9386916f7f..e96d131880 100644 --- a/src/Cli.Tests/AddEntityTests.cs +++ b/src/Cli.Tests/AddEntityTests.cs @@ -633,5 +633,259 @@ 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 or disabled + /// + [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: source, + permissions: new string[] { "anonymous", "*" }, + entity: entity, + 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: null + ); + + VerifySettings settings = new(); + settings.UseParameters(mcpDmlTools, source); + return ExecuteVerifyTest(options, settings: settings); + } + + /// + /// 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/ModuleInitializer.cs b/src/Cli.Tests/ModuleInitializer.cs index a75a882968..a03dcddd10 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/UpdateEntityTests.cs b/src/Cli.Tests/UpdateEntityTests.cs index b05392aa81..cac2db0a83 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,419 @@ 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 from false to true, or true to false + /// Tests actual update scenario where existing MCP config is modified + /// + [DataTestMethod] + [DataRow("true", "false", DisplayName = "TestUpdateTableEntityWithMcpDmlToolsEnabled")] + [DataRow("false", "true", DisplayName = "TestUpdateTableEntityWithMcpDmlToolsDisabled")] + public Task TestUpdateTableEntityWithMcpDmlTools(string newMcpDmlTools, string initialMcpDmlTools) + { + 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: newMcpDmlTools, + mcpCustomTool: null + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""MyEntity"": { + ""source"": ""MyTable"", + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""*""] + } + ], + ""mcp"": " + initialMcpDmlTools + @" + } + } + }"; + + VerifySettings settings = new(); + settings.UseParameters(newMcpDmlTools); + return ExecuteVerifyTest(initialConfig, options, settings: settings); + } + + /// + /// 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() + { + 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""] + } + ], + ""mcp"": { + ""custom-tool"": false + } + } + } + }"; + + return ExecuteVerifyTest(initialConfig, options); + } + + /// + /// Test updating stored procedure with both MCP properties + /// Updates from both true to custom-tool=true, dml-tools=false + /// + [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""] + } + ], + ""mcp"": { + ""custom-tool"": false, + ""dml-tools"": true + } + } + } + }"; + + return ExecuteVerifyTest(initialConfig, options); + } + + /// + /// Test updating stored procedure with both MCP properties enabled + /// Updates from both false to both true + /// + [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""] + } + ], + ""mcp"": { + ""custom-tool"": false, + ""dml-tools"": false + } + } + } + }"; + + 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 } } 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..3b2b77d9b2 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. 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. Default value is false.")] + 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 679ccac668..77ccf88fe1 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); + EntityMcpOptions? mcpOptions = null; + + if (options.McpDmlTools is not null || options.McpCustomTool is not null) + { + mcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedure); + + if (mcpOptions is null) + { + _logger.LogError("Failed to construct MCP options."); + return false; + } + } // 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: mcpOptions); // Add entity to existing runtime config. IDictionary entities = new Dictionary(initialRuntimeConfig.Entities.Entities) @@ -1638,6 +1651,26 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, RuntimeConfig 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); + + // Construct and validate MCP options if provided + EntityMcpOptions? updatedMcpOptions = null; + if (options.McpDmlTools is not null || options.McpCustomTool is not null) + { + updatedMcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedureAfterUpdate); + if (updatedMcpOptions is null) + { + _logger.LogError("Failed to construct MCP options."); + return false; + } + } + else + { + // Keep existing MCP options if no updates provided + updatedMcpOptions = entity.Mcp; + } + if (!updatedGraphQLDetails.Enabled) { _logger.LogWarning("Disabling GraphQL for this entity will restrict its usage in relationships"); @@ -1874,7 +1907,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..48edd4411c 100644 --- a/src/Cli/Utils.cs +++ b/src/Cli/Utils.cs @@ -892,6 +892,57 @@ 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..b4ad0e9170 --- /dev/null +++ b/src/Config/Converters/EntityMcpOptionsConverterFactory.cs @@ -0,0 +1,123 @@ +// 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 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 + { + 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 + // Pass null for customToolEnabled to keep it as default (not user-provided) + return new EntityMcpOptions( + customToolEnabled: null, + dmlToolsEnabled: value + ); + } + + // Handle object representation + if (reader.TokenType == JsonTokenType.StartObject) + { + bool? customToolEnabled = null; + bool? dmlToolsEnabled = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return new EntityMcpOptions(customToolEnabled, dmlToolsEnabled); + } + + if (reader.TokenType == JsonTokenType.PropertyName) + { + string? propertyName = reader.GetString(); + reader.Read(); // Move to the value + + switch (propertyName) + { + case "custom-tool": + customToolEnabled = reader.GetBoolean(); + break; + case "dml-tools": + dmlToolsEnabled = reader.GetBoolean(); + break; + default: + throw new JsonException($"Unknown property '{propertyName}' in EntityMcpOptions"); + } + } + } + } + + throw new JsonException($"Unexpected token type {reader.TokenType} for EntityMcpOptions"); + } + + public override void Write(Utf8JsonWriter writer, EntityMcpOptions value, JsonSerializerOptions options) + { + if (value == null) + { + 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); + } + else if (value.UserProvidedCustomToolEnabled || value.UserProvidedDmlToolsEnabled) + { + // Write as object + writer.WriteStartObject(); + + if (value.UserProvidedCustomToolEnabled) + { + writer.WriteBoolean("custom-tool", value.CustomToolEnabled); + } + + if (value.UserProvidedDmlToolsEnabled) + { + writer.WriteBoolean("dml-tools", value.DmlToolEnabled); + } + + writer.WriteEndObject(); + } + } + } +} 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..ad928a21ab --- /dev/null +++ b/src/Config/ObjectModel/EntityMcpOptions.cs @@ -0,0 +1,59 @@ +// 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. + /// Defaults to true when not explicitly provided. + /// + [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.HasValue) + { + this.CustomToolEnabled = customToolEnabled.Value; + this.UserProvidedCustomToolEnabled = true; + } + + if (dmlToolsEnabled.HasValue) + { + this.DmlToolEnabled = dmlToolsEnabled.Value; + this.UserProvidedDmlToolsEnabled = true; + } + } + } +} diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index 0e94c99657..ab8937dbfd 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 new file mode 100644 index 0000000000..5ce34c9355 --- /dev/null +++ b/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs @@ -0,0 +1,389 @@ +// 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 + { + 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. + /// + [TestMethod] + public void DeserializeConfig_McpBooleanTrue_EnablesDmlToolsOnly() + { + // Arrange + string config = CreateConfig(@" + ""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")); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: true, expectedCustomTool: false); + } + + /// + /// Test that deserializing boolean 'false' shorthand correctly sets dml-tools disabled. + /// + [TestMethod] + public void DeserializeConfig_McpBooleanFalse_DisablesDmlTools() + { + // Arrange + string config = CreateConfig(@" + ""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")); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: false, expectedCustomTool: false); + } + + /// + /// Test that deserializing object format with both properties works correctly. + /// + [TestMethod] + public void DeserializeConfig_McpObject_SetsBothProperties() + { + // Arrange + 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); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: false, expectedCustomTool: true); + } + + /// + /// Test that deserializing object format with only dml-tools works. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithDmlToolsOnly_WorksCorrectly() + { + // Arrange + string config = CreateConfig(@" + ""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")); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: true, expectedCustomTool: false); + } + + /// + /// Test that entity without MCP configuration has null Mcp property. + /// + [TestMethod] + public void DeserializeConfig_NoMcp_HasNullMcpOptions() + { + // Arrange + string config = CreateConfig(@" + ""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")); + Assert.IsNull(runtimeConfig.Entities["Book"].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 = 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); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: true, expectedCustomTool: true); + } + + /// + /// Test that deserializing object format with both properties set to false works correctly. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithBothFalse_SetsCorrectly() + { + // Arrange + 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); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: false, expectedCustomTool: false); + } + + /// + /// Test that deserializing object format with only custom-tool works. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithCustomToolOnly_WorksCorrectly() + { + // Arrange + 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); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: true, expectedCustomTool: true); + } + + /// + /// 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")); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: true, expectedCustomTool: false); + + // Author: mcp = false + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Author")); + AssertEntityMcp(runtimeConfig.Entities["Author"], expectedDmlTools: false, expectedCustomTool: false); + + // Publisher: no mcp (null) + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Publisher")); + Assert.IsNull(runtimeConfig.Entities["Publisher"].Mcp, "Mcp should be null when not specified"); + + // GetBook: mcp object + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: false, expectedCustomTool: true); + } + + /// + /// Test that deserializing invalid MCP value (non-boolean, non-object) fails gracefully. + /// + [TestMethod] + public void DeserializeConfig_InvalidMcpValue_FailsGracefully() + { + // Arrange + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": ""invalid"" + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out _); + + // 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 = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": { + ""dml-tools"": true, + ""unknown-property"": true + } + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out _); + + // Assert + Assert.IsFalse(success, "Config parsing should fail with unknown MCP property"); + } + } +} diff --git a/src/Service.Tests/ModuleInitializer.cs b/src/Service.Tests/ModuleInitializer.cs index 907c60f652..89b7dbc3c4 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 3f8d52378c977ebe7a663650f38387c76e6c8511 Mon Sep 17 00:00:00 2001 From: Souvik Ghosh Date: Sat, 31 Jan 2026 09:56:53 +0530 Subject: [PATCH 2/3] Entity level check if MCP Tool is enabled (#3084) - Closes on #3017 The entity-level MCP configuration (`Entity.Mcp.DmlToolEnabled` or `Entity.Mcp.CustomToolEnabled`) can override runtime-level settings. `DmlToolEnabled` defaults to true when unspecified, while `CustomToolEnabled` defaults to false. However, this entity-level configuration was not being checked by the MCP tools when they execute. The tools only checked the runtime-level configuration (e.g. `RuntimeConfig.McpDmlTools.ReadRecords`, etc.). This change addresses this gap for both built-in/DML tools and custom tools by adding entity-level validation before tool execution. - Added entity-level configuration checks to all 6 MCP tools (5 DML tools + 1 custom tool) that validate entity-specific flags before executing operations - DML tools (read_records, create_record, update_record, delete_record, execute_entity) now check `entity.Mcp.DmlToolEnabled` and return a `ToolDisabled` error when disabled at the entity level. - DynamicCustomTool checks `entity.Mcp.CustomToolEnabled` and return a `ToolDisabled` error - Tests covering various scenarios - [ ] Integration Tests - [x] Unit Tests Not applicable since it's a config level change and have been tested based on config settings. --------- Co-authored-by: Aniruddh Munde --- .../BuiltInTools/CreateRecordTool.cs | 7 + .../BuiltInTools/DeleteRecordTool.cs | 7 + .../BuiltInTools/ExecuteEntityTool.cs | 9 +- .../BuiltInTools/ReadRecordsTool.cs | 7 + .../BuiltInTools/UpdateRecordTool.cs | 7 + .../Core/DynamicCustomTool.cs | 381 ++++++++++++ .../Utils/McpErrorHelpers.cs | 4 +- .../EntityLevelDmlToolConfigurationTests.cs | 550 ++++++++++++++++++ 8 files changed, 969 insertions(+), 3 deletions(-) create mode 100644 src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs create mode 100644 src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 1a944d115b..5ac12f5988 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -83,6 +83,13 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } + // Check entity-level DML tool configuration + if (runtimeConfig.Entities?.TryGetValue(entityName, out Entity? entity) == true && + entity.Mcp?.DmlToolEnabled == false) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); + } + if (!McpMetadataHelper.TryResolveMetadata( entityName, runtimeConfig, diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs index d7837c0103..bc8efa96fe 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -101,6 +101,13 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } + // Check entity-level DML tool configuration + if (config.Entities?.TryGetValue(entityName, out Entity? entity) == true && + entity.Mcp?.DmlToolEnabled == false) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); + } + // 4) Resolve metadata for entity existence if (!McpMetadataHelper.TryResolveMetadata( entityName, diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs index e780c8ddeb..8989680f9e 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs @@ -107,11 +107,18 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "Entity is required", logger); } + // Check entity-level DML tool configuration early (before metadata resolution) + if (config.Entities?.TryGetValue(entity, out Entity? entityForCheck) == true && + entityForCheck.Mcp?.DmlToolEnabled == false) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entity}'."); + } + IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); IQueryEngineFactory queryEngineFactory = serviceProvider.GetRequiredService(); // 4) Validate entity exists and is a stored procedure - if (!config.Entities.TryGetValue(entity, out Entity? entityConfig)) + if (config.Entities is null || !config.Entities.TryGetValue(entity, out Entity? entityConfig)) { return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{entity}' not found in configuration.", logger); } diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index 1ed91c30a8..64e73f0281 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -114,6 +114,13 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } + // Check entity-level DML tool configuration + if (runtimeConfig.Entities?.TryGetValue(entityName, out Entity? entity) == true && + entity.Mcp?.DmlToolEnabled == false) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); + } + if (root.TryGetProperty("select", out JsonElement selectElement)) { select = selectElement.GetString(); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index 195e27a0cd..ed2a9f3ce4 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -115,6 +115,13 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } + // Check entity-level DML tool configuration + if (config.Entities?.TryGetValue(entityName, out Entity? entity) == true && + entity.Mcp?.DmlToolEnabled == false) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); + } + IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); diff --git a/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs new file mode 100644 index 0000000000..ea2fa0cfea --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs @@ -0,0 +1,381 @@ +// 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.Core +{ + /// + /// Dynamic custom MCP tool generated from stored procedure entity configuration. + /// Each custom tool represents a single stored procedure exposed as a dedicated MCP tool. + /// + /// Note: The entity configuration is captured at tool construction time. If the RuntimeConfig + /// is hot-reloaded, GetToolMetadata() will return cached metadata (name, description, parameters) + /// from the original configuration. This is acceptable because: + /// 1. MCP clients typically call tools/list once at startup + /// 2. ExecuteAsync always validates against the current runtime configuration + /// 3. Cached metadata improves performance for repeated metadata requests + /// + 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 ?? $"Executes the {toolName} 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 from the request + 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); + } + + // Check if custom tool is still enabled for this entity + if (entityConfig.Mcp?.CustomToolEnabled != true) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"Custom tool is disabled for entity '{_entityName}'."); + } + + // 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() + { + Dictionary schema = new() + { + ["type"] = "object", + ["properties"] = new Dictionary() + }; + + if (_entity.Source.Parameters != null && _entity.Source.Parameters.Any()) + { + Dictionary properties = (Dictionary)schema["properties"]; + + foreach (ParameterMetadata param in _entity.Source.Parameters) + { + // Note: Parameter type information is not available in ParameterMetadata, + // so we allow multiple JSON types to match the behavior of GetParameterValue + // that handles string, number, boolean, and null values. + properties[param.Name] = new Dictionary + { + ["type"] = new[] { "string", "number", "boolean", "null" }, + ["description"] = param.Description ?? $"Parameter {param.Name}" + }; + } + } + + 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"] = "Stored procedure executed successfully" + }; + + // Include parameters if any were provided + if (parameters?.Count > 0) + { + responseData["parameters"] = parameters; + } + + // Handle different result types + if (queryResult is OkObjectResult okResult && okResult.Value != null) + { + // Extract the actual data from the action result + 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 + { + // Serialize the value directly + 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", "You do not have permission to execute this entity", logger); + } + else + { + // Empty or unknown result + 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/Utils/McpErrorHelpers.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs index 75335b2db1..1a5c223798 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs @@ -19,9 +19,9 @@ public static CallToolResult PermissionDenied(string toolName, string entityName } // Centralized language for 'tool disabled' errors. Pass the tool name, e.g. "read_records". - public static CallToolResult ToolDisabled(string toolName, ILogger? logger) + public static CallToolResult ToolDisabled(string toolName, ILogger? logger, string? customMessage = null) { - string message = $"The {toolName} tool is disabled in the configuration."; + string message = customMessage ?? $"The {toolName} tool is disabled in the configuration."; return McpResponseBuilder.BuildErrorResult(toolName, Model.McpErrorCode.ToolDisabled.ToString(), message, logger); } } diff --git a/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs new file mode 100644 index 0000000000..d2f6554cd3 --- /dev/null +++ b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs @@ -0,0 +1,550 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Authorization; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Azure.DataApiBuilder.Mcp.Model; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; +using Moq; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Tests for entity-level DML tool configuration (GitHub issue #3017). + /// Ensures that DML tools respect the entity-level Mcp.DmlToolEnabled property + /// in addition to the runtime-level configuration. + /// + /// Coverage: + /// - Entity with DmlToolEnabled=false (tool disabled at entity level) + /// - Entity with DmlToolEnabled=true (tool enabled at entity level) + /// - Entity with no MCP configuration (defaults to enabled) + /// - Custom tool with CustomToolEnabled=false (runtime validation) + /// + [TestClass] + public class EntityLevelDmlToolConfigurationTests + { + /// + /// Verifies that DML tools respect entity-level DmlToolEnabled=false. + /// When an entity has DmlToolEnabled explicitly set to false, the tool should + /// return a ToolDisabled error even if the runtime-level tool is enabled. + /// + /// The type of tool to test (ReadRecords, CreateRecord, UpdateRecord, DeleteRecord, ExecuteEntity). + /// The JSON arguments for the tool. + /// Whether the entity is a stored procedure (uses different config). + [DataTestMethod] + [DataRow("ReadRecords", "{\"entity\": \"Book\"}", false, DisplayName = "ReadRecords respects entity-level DmlToolEnabled=false")] + [DataRow("CreateRecord", "{\"entity\": \"Book\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", false, DisplayName = "CreateRecord respects entity-level DmlToolEnabled=false")] + [DataRow("UpdateRecord", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", false, DisplayName = "UpdateRecord respects entity-level DmlToolEnabled=false")] + [DataRow("DeleteRecord", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}}", false, DisplayName = "DeleteRecord respects entity-level DmlToolEnabled=false")] + [DataRow("ExecuteEntity", "{\"entity\": \"GetBook\"}", true, DisplayName = "ExecuteEntity respects entity-level DmlToolEnabled=false")] + public async Task DmlTool_RespectsEntityLevelDmlToolDisabled(string toolType, string jsonArguments, bool isStoredProcedure) + { + // Arrange + RuntimeConfig config = isStoredProcedure + ? CreateConfigWithDmlToolDisabledStoredProcedure() + : CreateConfigWithDmlToolDisabledEntity(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + IMcpTool tool = CreateTool(toolType); + + JsonDocument arguments = JsonDocument.Parse(jsonArguments); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); + + JsonElement content = await RunToolAsync(tool, arguments, serviceProvider); + AssertToolDisabledError(content); + } + + /// + /// Verifies that DML tools work normally when entity-level DmlToolEnabled is not set to false. + /// This test ensures the entity-level check doesn't break the normal flow when either: + /// - DmlToolEnabled=true (explicitly enabled) + /// - entity.Mcp is null (defaults to enabled) + /// + /// The test scenario description. + /// Whether to include MCP config with DmlToolEnabled=true (false means no MCP config). + [DataTestMethod] + [DataRow("DmlToolEnabled=true", true, DisplayName = "ReadRecords works when entity has DmlToolEnabled=true")] + [DataRow("No MCP config", false, DisplayName = "ReadRecords works when entity has no MCP config")] + public async Task ReadRecords_WorksWhenNotDisabledAtEntityLevel(string scenario, bool useMcpConfig) + { + // Arrange + RuntimeConfig config = useMcpConfig + ? CreateConfigWithDmlToolEnabledEntity() + : CreateConfigWithEntityWithoutMcpConfig(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + ReadRecordsTool tool = new(); + + JsonDocument arguments = JsonDocument.Parse("{\"entity\": \"Book\"}"); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + // Should not be a ToolDisabled error - might be other errors (e.g., database connection) + // but that's OK for this test. We just want to ensure it passes the entity-level check. + if (result.IsError == true) + { + JsonElement content = await RunToolAsync(tool, arguments, serviceProvider); + + if (content.TryGetProperty("error", out JsonElement error) && + error.TryGetProperty("type", out JsonElement errorType)) + { + string errorTypeValue = errorType.GetString(); + Assert.AreNotEqual("ToolDisabled", errorTypeValue, + $"Should not get ToolDisabled error for scenario: {scenario}"); + } + } + } + + /// + /// Verifies the precedence of runtime-level vs entity-level configuration. + /// When runtime-level tool is disabled, entity-level DmlToolEnabled=true should NOT override it. + /// This validates that runtime-level acts as a global gate that takes precedence. + /// + [TestMethod] + public async Task ReadRecords_RuntimeDisabledTakesPrecedenceOverEntityEnabled() + { + // Arrange - Runtime has readRecords=false, but entity has DmlToolEnabled=true + RuntimeConfig config = CreateConfigWithRuntimeDisabledButEntityEnabled(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + ReadRecordsTool tool = new(); + + JsonDocument arguments = JsonDocument.Parse("{\"entity\": \"Book\"}"); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + Assert.IsTrue(result.IsError == true, "Expected error when runtime-level tool is disabled"); + + JsonElement content = await RunToolAsync(tool, arguments, serviceProvider); + AssertToolDisabledError(content); + + // Verify the error is due to runtime-level, not entity-level + // (The error message should NOT mention entity-specific disabling) + if (content.TryGetProperty("error", out JsonElement error) && + error.TryGetProperty("message", out JsonElement errorMessage)) + { + string message = errorMessage.GetString() ?? string.Empty; + Assert.IsFalse(message.Contains("entity"), + "Error should be from runtime-level check, not entity-level check"); + } + } + + /// + /// Verifies that DynamicCustomTool respects entity-level CustomToolEnabled configuration. + /// If CustomToolEnabled becomes false (e.g., after config hot-reload), ExecuteAsync should + /// return a ToolDisabled error. This ensures runtime validation even though tool instances + /// are created at startup. + /// + [TestMethod] + public async Task DynamicCustomTool_RespectsCustomToolDisabled() + { + // Arrange - Create a stored procedure entity with CustomToolEnabled=false + RuntimeConfig config = CreateConfigWithCustomToolDisabled(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + + // Create the DynamicCustomTool with the entity that has CustomToolEnabled initially true + // (simulating tool created at startup, then config changed) + Entity initialEntity = new( + Source: new("get_book", EntitySourceType.StoredProcedure, null, null), + GraphQL: new("GetBook", "GetBook"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: true) + ); + + Azure.DataApiBuilder.Mcp.Core.DynamicCustomTool tool = new("GetBook", initialEntity); + + JsonDocument arguments = JsonDocument.Parse("{}"); + + // Act - Execute with config that has CustomToolEnabled=false + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + Assert.IsTrue(result.IsError == true, "Expected error when CustomToolEnabled=false in runtime config"); + + JsonElement content = await RunToolAsync(tool, arguments, serviceProvider); + AssertToolDisabledError(content, "Custom tool is disabled for entity 'GetBook'"); + } + + #region Helper Methods + + /// + /// Helper method to execute an MCP tool and return the parsed JsonElement from the result. + /// + /// The MCP tool to execute. + /// The JSON arguments for the tool. + /// The service provider with dependencies. + /// The parsed JsonElement from the tool's response. + private static async Task RunToolAsync(IMcpTool tool, JsonDocument arguments, IServiceProvider serviceProvider) + { + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + return JsonDocument.Parse(firstContent.Text).RootElement; + } + + /// + /// Helper method to assert that a JsonElement contains a ToolDisabled error. + /// + /// The JSON content to check for error. + /// Optional message fragment that should be present in the error message. + private static void AssertToolDisabledError(JsonElement content, string expectedMessageFragment = null) + { + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); + Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); + Assert.AreEqual("ToolDisabled", errorType.GetString()); + + if (expectedMessageFragment != null) + { + Assert.IsTrue(error.TryGetProperty("message", out JsonElement errorMessage)); + string message = errorMessage.GetString() ?? string.Empty; + Assert.IsTrue(message.Contains(expectedMessageFragment), + $"Expected error message to contain '{expectedMessageFragment}', but got: {message}"); + } + } + + /// + /// Helper method to create an MCP tool instance based on the tool type. + /// + /// The type of tool to create (ReadRecords, CreateRecord, UpdateRecord, DeleteRecord, ExecuteEntity). + /// An instance of the requested tool. + private static IMcpTool CreateTool(string toolType) + { + return toolType switch + { + "ReadRecords" => new ReadRecordsTool(), + "CreateRecord" => new CreateRecordTool(), + "UpdateRecord" => new UpdateRecordTool(), + "DeleteRecord" => new DeleteRecordTool(), + "ExecuteEntity" => new ExecuteEntityTool(), + _ => throw new ArgumentException($"Unknown tool type: {toolType}", nameof(toolType)) + }; + } + + /// + /// Creates a runtime config with a table entity that has DmlToolEnabled=false. + /// + private static RuntimeConfig CreateConfigWithDmlToolDisabledEntity() + { + Dictionary entities = new() + { + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Create, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Update, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Delete, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: false) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: true, + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true + ) + ), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + /// + /// Creates a runtime config with a stored procedure that has DmlToolEnabled=false. + /// + private static RuntimeConfig CreateConfigWithDmlToolDisabledStoredProcedure() + { + Dictionary entities = new() + { + ["GetBook"] = new Entity( + Source: new("get_book", EntitySourceType.StoredProcedure, null, null), + GraphQL: new("GetBook", "GetBook"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: false) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: true, + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true + ) + ), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + /// + /// Creates a runtime config with a table entity that has DmlToolEnabled=true. + /// + private static RuntimeConfig CreateConfigWithDmlToolEnabledEntity() + { + Dictionary entities = new() + { + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: true, + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true + ) + ), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + /// + /// Creates a runtime config with a table entity that has no MCP configuration. + /// + private static RuntimeConfig CreateConfigWithEntityWithoutMcpConfig() + { + Dictionary entities = new() + { + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: null + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: true, + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true + ) + ), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + /// + /// Creates a runtime config with a stored procedure that has CustomToolEnabled=false. + /// Used to test DynamicCustomTool runtime validation. + /// + private static RuntimeConfig CreateConfigWithCustomToolDisabled() + { + Dictionary entities = new() + { + ["GetBook"] = new Entity( + Source: new("get_book", EntitySourceType.StoredProcedure, null, null), + GraphQL: new("GetBook", "GetBook"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: true, + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true + ) + ), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + /// + /// Creates a runtime config where runtime-level readRecords is disabled, + /// but entity-level DmlToolEnabled is true. This tests precedence behavior. + /// + private static RuntimeConfig CreateConfigWithRuntimeDisabledButEntityEnabled() + { + Dictionary entities = new() + { + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: false, // Runtime-level DISABLED + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true + ) + ), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + /// + /// Creates a service provider with mocked dependencies for testing MCP tools. + /// + private static IServiceProvider CreateServiceProvider(RuntimeConfig config) + { + ServiceCollection services = new(); + + RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config); + services.AddSingleton(configProvider); + + Mock mockAuthResolver = new(); + mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny())).Returns(true); + services.AddSingleton(mockAuthResolver.Object); + + Mock mockHttpContext = new(); + Mock mockRequest = new(); + mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns("anonymous"); + mockHttpContext.Setup(x => x.Request).Returns(mockRequest.Object); + + Mock mockHttpContextAccessor = new(); + mockHttpContextAccessor.Setup(x => x.HttpContext).Returns(mockHttpContext.Object); + services.AddSingleton(mockHttpContextAccessor.Object); + + services.AddLogging(); + + return services.BuildServiceProvider(); + } + + #endregion + } +} From e5bf26fdc43f06de985cf10e935e6f1eea0c85c1 Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Fri, 6 Mar 2026 12:10:45 -0800 Subject: [PATCH 3/3] fix(mcp): allow create_record for views and added tests to verify the same. (#3196) ## Why make this change? Closes #3194 - MCP `create_record` tool incorrectly blocks views with error `"The create_record tool is only available for tables."` 1. Views are the required workaround for unsupported SQL data types (e.g., vector columns). Users could create views that omit unsupported columns and perform DML operations against those views. 2. This bug prevents INSERT operations via MCP on view entities, breaking a critical workflow for vector database scenarios. ## What is this change? 1. Modified `CreateRecordTool.cs` to allow both tables and views to pass source type validation 2. Changed `else` block (which caught views) to`else if (EntitySourceType.StoredProcedure)` so only stored procedures are blocked 3. Views now fall through to the mutation engine, which already supports INSERT on updateable views 4. Updated error message to `"The create_record tool is only available for tables and views."` 5. Added 8 unit tests validating all DML tools (CreateRecord, ReadRecords, UpdateRecord, DeleteRecord) work with both Table and View source types ## How was this tested? - [ ] Integration Tests - [X] Unit Tests 1. `DmlTool_AllowsTablesAndViews` - 8 DataRow test cases verifying no `InvalidCreateTarget` error for views 2. Existing REST integration tests `InsertOneInViewTest` already validate view INSERT via same mutation engine Manually tested via MCP Inspector, to verify `create_record` calls succeeds on a view. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Aniruddh Munde --- .../BuiltInTools/CreateRecordTool.cs | 20 +- .../BuiltInTools/ReadRecordsTool.cs | 6 + .../BuiltInTools/UpdateRecordTool.cs | 6 + .../EntityLevelDmlToolConfigurationTests.cs | 175 +++++++++++++++++- 4 files changed, 194 insertions(+), 13 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 5ac12f5988..cab4b69bdb 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -124,17 +124,23 @@ public async Task ExecuteAsync( } JsonElement insertPayloadRoot = dataElement.Clone(); + + // Validate it's a table or view - stored procedures use execute_entity + if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View) + { + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. For stored procedures, use the execute_entity tool instead.", logger); + } + InsertRequestContext insertRequestContext = new( entityName, dbObject, insertPayloadRoot, EntityActionOperation.Insert); - RequestValidator requestValidator = serviceProvider.GetRequiredService(); - - // Only validate tables + // Only validate tables. For views, skip validation and let the database handle any errors. if (dbObject.SourceType is EntitySourceType.Table) { + RequestValidator requestValidator = serviceProvider.GetRequiredService(); try { requestValidator.ValidateInsertRequestContext(insertRequestContext); @@ -144,14 +150,6 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {ex.Message}", logger); } } - else - { - return McpResponseBuilder.BuildErrorResult( - toolName, - "InvalidCreateTarget", - "The create_record tool is only available for tables.", - logger); - } IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); DatabaseType databaseType = sqlMetadataProvider.GetDatabaseType(); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index 64e73f0281..dbbc338c76 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -158,6 +158,12 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } + // Validate it's a table or view + if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View) + { + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. For stored procedures, use the execute_entity tool instead.", logger); + } + // Authorization check in the existing entity IAuthorizationResolver authResolver = serviceProvider.GetRequiredService(); IAuthorizationService authorizationService = serviceProvider.GetRequiredService(); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index ed2a9f3ce4..883ddde02e 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -137,6 +137,12 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } + // Validate it's a table or view + if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View) + { + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. For stored procedures, use the execute_entity tool instead.", logger); + } + // 5) Authorization after we have a known entity IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService(); HttpContext? httpContext = httpContextAccessor.HttpContext; diff --git a/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs index d2f6554cd3..278bc95cfb 100644 --- a/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs +++ b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs @@ -7,9 +7,12 @@ using System.Threading; using System.Threading.Tasks; using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Mcp.BuiltInTools; using Azure.DataApiBuilder.Mcp.Model; using Microsoft.AspNetCore.Http; @@ -188,8 +191,74 @@ public async Task DynamicCustomTool_RespectsCustomToolDisabled() AssertToolDisabledError(content, "Custom tool is disabled for entity 'GetBook'"); } + #region View Support Tests + + /// + /// Data-driven test to verify all DML tools allow both table and view entities. + /// This is critical for scenarios like vector data type support, where users must: + /// - Create a view that omits unsupported columns (e.g., vector columns) + /// - Perform DML operations against that view + /// + /// The tool type to test. + /// The entity source type (Table or View). + /// The entity name to use. + /// The JSON arguments for the tool. + [DataTestMethod] + [DataRow("CreateRecord", "Table", "{\"entity\": \"Book\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", DisplayName = "CreateRecord allows Table")] + [DataRow("CreateRecord", "View", "{\"entity\": \"BookView\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", DisplayName = "CreateRecord allows View")] + [DataRow("ReadRecords", "Table", "{\"entity\": \"Book\"}", DisplayName = "ReadRecords allows Table")] + [DataRow("ReadRecords", "View", "{\"entity\": \"BookView\"}", DisplayName = "ReadRecords allows View")] + [DataRow("UpdateRecord", "Table", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", DisplayName = "UpdateRecord allows Table")] + [DataRow("UpdateRecord", "View", "{\"entity\": \"BookView\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", DisplayName = "UpdateRecord allows View")] + [DataRow("DeleteRecord", "Table", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}}", DisplayName = "DeleteRecord allows Table")] + [DataRow("DeleteRecord", "View", "{\"entity\": \"BookView\", \"keys\": {\"id\": 1}}", DisplayName = "DeleteRecord allows View")] + public async Task DmlTool_AllowsTablesAndViews(string toolType, string sourceType, string jsonArguments) + { + // Arrange + RuntimeConfig config = sourceType == "View" + ? CreateConfigWithViewEntity() + : CreateConfigWithDmlToolEnabledEntity(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + IMcpTool tool = CreateTool(toolType); + + JsonDocument arguments = JsonDocument.Parse(jsonArguments); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert - Should NOT be a source type blocking error (InvalidEntity) + // Other errors like missing metadata are acceptable since we're testing source type validation + if (result.IsError == true) + { + JsonElement content = ParseResultContent(result); + + if (content.TryGetProperty("error", out JsonElement error) && + error.TryGetProperty("type", out JsonElement errorType)) + { + string errorTypeValue = errorType.GetString() ?? string.Empty; + + // This error type indicates the tool is blocking based on source type + Assert.AreNotEqual("InvalidEntity", errorTypeValue, + $"{sourceType} entities should not be blocked with InvalidEntity"); + } + } + } + + #endregion + #region Helper Methods + /// + /// Helper method to parse the JSON content from a CallToolResult without re-executing the tool. + /// + /// The result from executing an MCP tool. + /// The parsed JsonElement from the result's content. + private static JsonElement ParseResultContent(CallToolResult result) + { + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + return JsonDocument.Parse(firstContent.Text).RootElement; + } + /// /// Helper method to execute an MCP tool and return the parsed JsonElement from the result. /// @@ -200,8 +269,7 @@ public async Task DynamicCustomTool_RespectsCustomToolDisabled() private static async Task RunToolAsync(IMcpTool tool, JsonDocument arguments, IServiceProvider serviceProvider) { CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); - TextContentBlock firstContent = (TextContentBlock)result.Content[0]; - return JsonDocument.Parse(firstContent.Text).RootElement; + return ParseResultContent(result); } /// @@ -517,8 +585,63 @@ private static RuntimeConfig CreateConfigWithRuntimeDisabledButEntityEnabled() ); } + /// + /// Creates a runtime config with a view entity. + /// This is the key scenario for vector data type support. + /// + private static RuntimeConfig CreateConfigWithViewEntity() + { + Dictionary entities = new() + { + ["BookView"] = new Entity( + Source: new EntitySource( + Object: "dbo.vBooks", + Type: EntitySourceType.View, + Parameters: null, + KeyFields: new[] { "id" } + ), + GraphQL: new("BookView", "BookViews"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Create, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Update, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Delete, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: true, + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true + ) + ), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + /// /// Creates a service provider with mocked dependencies for testing MCP tools. + /// Includes metadata provider mocks so tests can reach source type validation. /// private static IServiceProvider CreateServiceProvider(RuntimeConfig config) { @@ -540,6 +663,54 @@ private static IServiceProvider CreateServiceProvider(RuntimeConfig config) mockHttpContextAccessor.Setup(x => x.HttpContext).Returns(mockHttpContext.Object); services.AddSingleton(mockHttpContextAccessor.Object); + // Add metadata provider mocks so tests can reach source type validation. + // This is required for DmlTool_AllowsTablesAndViews to actually test the source type behavior. + Mock mockSqlMetadataProvider = new(); + Dictionary entityToDatabaseObject = new(); + + // Add database objects for each entity in the config + if (config.Entities != null) + { + foreach (KeyValuePair kvp in config.Entities) + { + string entityName = kvp.Key; + Entity entity = kvp.Value; + EntitySourceType sourceType = entity.Source.Type ?? EntitySourceType.Table; + + DatabaseObject dbObject; + if (sourceType == EntitySourceType.View) + { + dbObject = new DatabaseView("dbo", entity.Source.Object) + { + SourceType = EntitySourceType.View + }; + } + else if (sourceType == EntitySourceType.StoredProcedure) + { + dbObject = new DatabaseStoredProcedure("dbo", entity.Source.Object) + { + SourceType = EntitySourceType.StoredProcedure + }; + } + else + { + dbObject = new DatabaseTable("dbo", entity.Source.Object) + { + SourceType = EntitySourceType.Table + }; + } + + entityToDatabaseObject[entityName] = dbObject; + } + } + + mockSqlMetadataProvider.Setup(x => x.EntityToDatabaseObject).Returns(entityToDatabaseObject); + mockSqlMetadataProvider.Setup(x => x.GetDatabaseType()).Returns(DatabaseType.MSSQL); + + Mock mockMetadataProviderFactory = new(); + mockMetadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(mockSqlMetadataProvider.Object); + services.AddSingleton(mockMetadataProviderFactory.Object); + services.AddLogging(); return services.BuildServiceProvider();