diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 80cfd953ad..61ab7474b7 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 e00dc00a89..a0c882ae74 100644 --- a/src/Cli.Tests/ModuleInitializer.cs +++ b/src/Cli.Tests/ModuleInitializer.cs @@ -65,6 +65,10 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); + // Ignore the UserProvidedCustomToolEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedCustomToolEnabled); + // Ignore the UserProvidedDmlToolsEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedDmlToolsEnabled); // Ignore the IsRequestBodyStrict as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsRequestBodyStrict); // Ignore the IsGraphQLEnabled as that's unimportant from a test standpoint. diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpProperties.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpProperties.verified.txt new file mode 100644 index 0000000000..d7d3ed0056 --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpProperties.verified.txt @@ -0,0 +1,61 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [ + { + UpdateBook: { + Source: { + Object: dbo.UpdateBook, + Type: stored-procedure + }, + GraphQL: { + Singular: UpdateBook, + Plural: UpdateBooks, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: false + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpPropertiesEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpPropertiesEnabled.verified.txt new file mode 100644 index 0000000000..aa30025561 --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpPropertiesEnabled.verified.txt @@ -0,0 +1,61 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [ + { + GetAllBooks: { + Source: { + Object: dbo.GetAllBooks, + Type: stored-procedure + }, + GraphQL: { + Singular: GetAllBooks, + Plural: GetAllBooks, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt new file mode 100644 index 0000000000..576e84f6d8 --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt @@ -0,0 +1,61 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [ + { + GetBookById: { + Source: { + Object: dbo.GetBookById, + Type: stored-procedure + }, + GraphQL: { + Singular: GetBookById, + Plural: GetBookByIds, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=false_source=authors.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=false_source=authors.verified.txt new file mode 100644 index 0000000000..51a278d2a3 --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=false_source=authors.verified.txt @@ -0,0 +1,57 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [ + { + Author: { + Source: { + Object: authors, + Type: Table + }, + GraphQL: { + Singular: Author, + Plural: Authors, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: false + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=true_source=books.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=true_source=books.verified.txt new file mode 100644 index 0000000000..4dc41a4d45 --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=true_source=books.verified.txt @@ -0,0 +1,57 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [ + { + Book: { + Source: { + Object: books, + Type: Table + }, + GraphQL: { + Singular: Book, + Plural: Books, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpProperties.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpProperties.verified.txt new file mode 100644 index 0000000000..627e8e9e01 --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpProperties.verified.txt @@ -0,0 +1,64 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + UpdateBook: { + Source: { + Type: stored-procedure + }, + GraphQL: { + Singular: UpdateBook, + Plural: UpdateBooks, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: false + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpPropertiesEnabled.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpPropertiesEnabled.verified.txt new file mode 100644 index 0000000000..47d181d59d --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpPropertiesEnabled.verified.txt @@ -0,0 +1,64 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + GetAllBooks: { + Source: { + Type: stored-procedure + }, + GraphQL: { + Singular: GetAllBooks, + Plural: GetAllBooks, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt new file mode 100644 index 0000000000..4cb7fb45ef --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt @@ -0,0 +1,64 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + GetBookById: { + Source: { + Type: stored-procedure + }, + GraphQL: { + Singular: GetBookById, + Plural: GetBookByIds, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=false.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=false.verified.txt new file mode 100644 index 0000000000..42cb419190 --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=false.verified.txt @@ -0,0 +1,61 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + MyEntity: { + Source: { + Object: MyTable, + Type: Table + }, + GraphQL: { + Singular: MyEntity, + Plural: MyEntities, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: false + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=true.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=true.verified.txt new file mode 100644 index 0000000000..4084b29397 --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=true.verified.txt @@ -0,0 +1,61 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + MyEntity: { + Source: { + Object: MyTable, + Type: Table + }, + GraphQL: { + Singular: MyEntity, + Plural: MyEntities, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/UpdateEntityTests.cs b/src/Cli.Tests/UpdateEntityTests.cs index 3a106c0adc..2cc03dd8f8 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 1d673c11e3..8fcc7cae31 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) @@ -1621,6 +1634,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"); @@ -1857,7 +1890,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 bad5aa8680..6d6cf6d51b 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -314,6 +314,7 @@ public static JsonSerializerOptions GetSerializationOptions( options.Converters.Add(new EntityActionConverterFactory()); options.Converters.Add(new DataSourceFilesConverter()); options.Converters.Add(new EntityCacheOptionsConverterFactory(replacementSettings)); + options.Converters.Add(new EntityMcpOptionsConverterFactory()); options.Converters.Add(new RuntimeCacheOptionsConverterFactory()); options.Converters.Add(new RuntimeCacheLevel2OptionsConverterFactory()); options.Converters.Add(new MultipleCreateOptionsConverter()); diff --git a/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs b/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs 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 ba0407ecd5..f0c3984a72 100644 --- a/src/Service.Tests/ModuleInitializer.cs +++ b/src/Service.Tests/ModuleInitializer.cs @@ -69,6 +69,10 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); + // Ignore the UserProvidedCustomToolEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedCustomToolEnabled); + // Ignore the UserProvidedDmlToolsEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedDmlToolsEnabled); // Ignore the CosmosDataSourceUsed as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.CosmosDataSourceUsed); // Ignore the IsRequestBodyStrict as that's unimportant from a test standpoint.