diff --git a/MssqlMcp/dotnet/MssqlMcp.Tests/ProcedureAndFunctionToolsUnitTests.cs b/MssqlMcp/dotnet/MssqlMcp.Tests/ProcedureAndFunctionToolsUnitTests.cs new file mode 100644 index 0000000..49c08a1 --- /dev/null +++ b/MssqlMcp/dotnet/MssqlMcp.Tests/ProcedureAndFunctionToolsUnitTests.cs @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.Extensions.Logging; +using Moq; +using Mssql.McpServer; + +namespace MssqlMcp.Tests +{ + /// + /// Unit tests for stored procedure and function tools. + /// These test the business logic and parameter validation without database dependencies. + /// + public sealed class ProcedureAndFunctionToolsUnitTests + { + private readonly Mock _connectionFactoryMock; + private readonly Mock> _loggerMock; + private readonly Tools _tools; + + public ProcedureAndFunctionToolsUnitTests() + { + _connectionFactoryMock = new Mock(); + _loggerMock = new Mock>(); + _tools = new Tools(_connectionFactoryMock.Object, _loggerMock.Object); + } + + #region CreateProcedure Tests + + [Theory] + [InlineData("CREATE PROCEDURE dbo.TestProc AS BEGIN SELECT 1 END")] + [InlineData("CREATE OR ALTER PROCEDURE TestProc AS SELECT * FROM Users")] + [InlineData("create procedure MyProc (@id int) as begin select @id end")] + [InlineData("CREATE PROCEDURE [dbo].[My Proc] AS BEGIN PRINT 'Hello' END")] + public void CreateProcedure_ValidatesValidCreateStatements(string sql) + { + // Test that valid CREATE PROCEDURE statements pass validation + var trimmedSql = sql.Trim(); + Assert.True(trimmedSql.StartsWith("CREATE", StringComparison.OrdinalIgnoreCase)); + Assert.Contains("PROCEDURE", trimmedSql, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("SELECT * FROM Users")] + [InlineData("UPDATE Users SET Name = 'Test'")] + [InlineData("CREATE TABLE Test (Id INT)")] + [InlineData("CREATE FUNCTION TestFunc() RETURNS INT AS BEGIN RETURN 1 END")] + [InlineData("DROP PROCEDURE TestProc")] + [InlineData("ALTER PROCEDURE TestProc AS BEGIN SELECT 2 END")] + public void CreateProcedure_RejectsNonCreateProcedureStatements(string sql) + { + // Test that non-CREATE PROCEDURE statements are rejected + var trimmedSql = sql.Trim(); + var isValidCreateProcedure = trimmedSql.StartsWith("CREATE", StringComparison.OrdinalIgnoreCase) && + trimmedSql.Contains("PROCEDURE", StringComparison.OrdinalIgnoreCase); + Assert.False(isValidCreateProcedure); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void CreateProcedure_RejectsEmptyOrWhitespaceSql(string sql) + { + // Test that empty or whitespace SQL is rejected + Assert.True(string.IsNullOrWhiteSpace(sql)); + } + + #endregion + + #region CreateFunction Tests + + [Theory] + [InlineData("CREATE FUNCTION dbo.TestFunc() RETURNS INT AS BEGIN RETURN 1 END")] + [InlineData("CREATE OR ALTER FUNCTION TestFunc(@id int) RETURNS TABLE AS RETURN SELECT @id as Id")] + [InlineData("create function MyFunc (@param varchar(50)) returns varchar(100) as begin return @param + ' processed' end")] + [InlineData("CREATE FUNCTION [dbo].[My Function] () RETURNS INT AS BEGIN RETURN 42 END")] + public void CreateFunction_ValidatesValidCreateStatements(string sql) + { + // Test that valid CREATE FUNCTION statements pass validation + var trimmedSql = sql.Trim(); + Assert.True(trimmedSql.StartsWith("CREATE", StringComparison.OrdinalIgnoreCase)); + Assert.Contains("FUNCTION", trimmedSql, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("SELECT * FROM Users")] + [InlineData("UPDATE Users SET Name = 'Test'")] + [InlineData("CREATE TABLE Test (Id INT)")] + [InlineData("CREATE PROCEDURE TestProc AS BEGIN SELECT 1 END")] + [InlineData("DROP FUNCTION TestFunc")] + [InlineData("ALTER FUNCTION TestFunc() RETURNS INT AS BEGIN RETURN 2 END")] + public void CreateFunction_RejectsNonCreateFunctionStatements(string sql) + { + // Test that non-CREATE FUNCTION statements are rejected + var trimmedSql = sql.Trim(); + var isValidCreateFunction = trimmedSql.StartsWith("CREATE", StringComparison.OrdinalIgnoreCase) && + trimmedSql.Contains("FUNCTION", trimmedSql, StringComparison.OrdinalIgnoreCase); + Assert.False(isValidCreateFunction); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void CreateFunction_RejectsEmptyOrWhitespaceSql(string sql) + { + // Test that empty or whitespace SQL is rejected + Assert.True(string.IsNullOrWhiteSpace(sql)); + } + + #endregion + + #region ExecuteStoredProcedure Tests + + [Fact] + public void ExecuteStoredProcedure_ValidatesParameterTypes() + { + // Test that parameter dictionaries can handle various data types + var parameters = new Dictionary + { + { "StringParam", "test" }, + { "IntParam", 42 }, + { "DoubleParam", 3.14 }, + { "BoolParam", true }, + { "DateParam", DateTime.Now }, + { "NullParam", null! } + }; + + Assert.Equal(6, parameters.Count); + Assert.IsType(parameters["StringParam"]); + Assert.IsType(parameters["IntParam"]); + Assert.IsType(parameters["DoubleParam"]); + Assert.IsType(parameters["BoolParam"]); + Assert.IsType(parameters["DateParam"]); + Assert.Null(parameters["NullParam"]); + } + + [Theory] + [InlineData("ValidParam")] + [InlineData("Another_Valid123")] + [InlineData("@ParamWithAt")] + [InlineData("CamelCaseParam")] + [InlineData("snake_case_param")] + public void ExecuteStoredProcedure_AcceptsValidParameterNames(string paramName) + { + // Test that valid parameter names are accepted + var parameters = new Dictionary { { paramName, "value" } }; + Assert.True(parameters.ContainsKey(paramName)); + Assert.Equal("value", parameters[paramName]); + } + + [Fact] + public void ExecuteStoredProcedure_HandlesEmptyParameters() + { + // Test that null or empty parameter dictionary is handled + Dictionary? nullParams = null; + var emptyParams = new Dictionary(); + + Assert.Null(nullParams); + Assert.NotNull(emptyParams); + Assert.Empty(emptyParams); + } + + #endregion + + #region ExecuteFunction Tests + + [Fact] + public void ExecuteFunction_ValidatesParameterTypes() + { + // Test that parameter dictionaries can handle various data types for functions + var parameters = new Dictionary + { + { "Id", 1 }, + { "Name", "TestName" }, + { "StartDate", DateTime.Today }, + { "IsActive", true }, + { "Score", 95.5 } + }; + + Assert.Equal(5, parameters.Count); + Assert.Contains("Id", parameters.Keys); + Assert.Contains("Name", parameters.Keys); + Assert.Contains("StartDate", parameters.Keys); + Assert.Contains("IsActive", parameters.Keys); + Assert.Contains("Score", parameters.Keys); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ExecuteFunction_ValidatesEmptyFunctionName(string functionName) + { + // Test function name validation + Assert.True(string.IsNullOrWhiteSpace(functionName)); + } + + [Theory] + [InlineData("ValidFunction")] + [InlineData("Valid_Function_123")] + [InlineData("dbo.ValidFunction")] + [InlineData("[schema].[My Function]")] + public void ExecuteFunction_AcceptsValidFunctionNames(string functionName) + { + // Test function name validation for valid names + Assert.False(string.IsNullOrWhiteSpace(functionName)); + Assert.True(functionName.Length > 0); + } + + #endregion + + #region General Validation Tests + + [Fact] + public void Tools_Constructor_AcceptsValidDependencies() + { + // Test that Tools can be constructed with mocked dependencies + var factory = new Mock(); + var logger = new Mock>(); + + var tools = new Tools(factory.Object, logger.Object); + + Assert.NotNull(tools); + } + + [Fact] + public void SqlConnectionFactory_Interface_CanBeMocked() + { + // Test that the interface exists and can be mocked + Assert.NotNull(_connectionFactoryMock); + Assert.NotNull(_connectionFactoryMock.Object); + } + + [Theory] + [InlineData("dbo.MyProcedure")] + [InlineData("schema.MyFunction")] + [InlineData("[My Schema].[My Object]")] + [InlineData("SimpleObject")] + public void DatabaseObjectNames_ValidateSchemaQualifiedNames(string objectName) + { + // Test that schema-qualified names are handled properly + Assert.False(string.IsNullOrWhiteSpace(objectName)); + + // Check if it's schema-qualified + var hasSchema = objectName.Contains('.'); + if (hasSchema) + { + var parts = objectName.Split('.'); + Assert.True(parts.Length >= 2); + Assert.All(parts, part => Assert.False(string.IsNullOrWhiteSpace(part.Trim('[', ']')))); + } + } + + [Fact] + public void ParameterDictionary_HandlesNullValues() + { + // Test that parameter dictionaries can handle null values + var parameters = new Dictionary + { + { "NullParam", null! }, + { "StringParam", "value" }, + { "IntParam", 42 } + }; + + Assert.Equal(3, parameters.Count); + Assert.Null(parameters["NullParam"]); + Assert.Equal("value", parameters["StringParam"]); + Assert.Equal(42, parameters["IntParam"]); + } + + [Fact] + public void ParameterDictionary_HandlesVariousTypes() + { + // Test that parameter dictionaries can handle various data types + var parameters = new Dictionary + { + { "StringParam", "test" }, + { "IntParam", 42 }, + { "DoubleParam", 3.14 }, + { "BoolParam", true }, + { "DateParam", DateTime.Now }, + { "DecimalParam", 123.45m }, + { "GuidParam", Guid.NewGuid() } + }; + + Assert.Equal(7, parameters.Count); + Assert.IsType(parameters["StringParam"]); + Assert.IsType(parameters["IntParam"]); + Assert.IsType(parameters["DoubleParam"]); + Assert.IsType(parameters["BoolParam"]); + Assert.IsType(parameters["DateParam"]); + Assert.IsType(parameters["DecimalParam"]); + Assert.IsType(parameters["GuidParam"]); + } + + #endregion + } +} \ No newline at end of file diff --git a/MssqlMcp/dotnet/MssqlMcp.Tests/README.md b/MssqlMcp/dotnet/MssqlMcp.Tests/README.md new file mode 100644 index 0000000..3062790 --- /dev/null +++ b/MssqlMcp/dotnet/MssqlMcp.Tests/README.md @@ -0,0 +1,68 @@ +# Test Documentation + +This project contains two types of tests to ensure comprehensive coverage: + +## Unit Tests (`ToolsUnitTests.cs`) +**Purpose**: Fast, isolated tests that don't require external dependencies. + +- ✅ **No database required** - Run anywhere, anytime +- ✅ **Fast execution** - Complete in seconds +- ✅ **Parameter validation** - Test input validation logic +- ✅ **Business logic** - Test pure functions and data structures +- ✅ **Mocking** - Test interfaces and dependency injection + +**Run unit tests only:** +```bash +dotnet test --filter "FullyQualifiedName~ToolsUnitTests" +``` + +## Integration Tests (`UnitTests.cs` -> `MssqlMcpTests`) +**Purpose**: End-to-end testing with real SQL Server database. + +- 🔌 **Database required** - Tests full SQL Server integration +- 📊 **Real data operations** - Creates tables, stored procedures, functions +- 🧪 **Complete workflows** - Tests actual MCP tool execution +- ⚡ **14 original tests** - Core CRUD and error handling scenarios + +**Prerequisites for integration tests:** +1. SQL Server running locally +2. Database named 'test' +3. Set environment variable: + ```bash + SET CONNECTION_STRING=Server=.;Database=test;Trusted_Connection=True;TrustServerCertificate=True + ``` + +**Run integration tests only:** +```bash +dotnet test --filter "FullyQualifiedName~MssqlMcpTests" +``` + +**Run all tests:** +```bash +dotnet test +``` + +## Test Coverage + +### ExecuteStoredProcedure Tool +- ✅ Unit: Parameter validation and structure +- ⚠️ Integration: **Not included** - Use unit tests for validation + +### ExecuteFunction Tool +- ✅ Unit: Parameter validation and structure +- ⚠️ Integration: **Not included** - Use unit tests for validation + +### All Other Tools +- ✅ Unit: Interface and dependency validation +- ✅ Integration: Full CRUD operations with real database (14 tests) + +## Best Practices + +1. **Run unit tests during development** - They're fast and catch logic errors +2. **Run integration tests before commits** - They verify end-to-end functionality +3. **Use unit tests for TDD** - Write failing unit tests, then implement features +4. **Use integration tests for deployment validation** - Verify database connectivity + +This approach follows the **Test Pyramid** principle: +- Many fast unit tests (base of pyramid) +- Fewer comprehensive integration tests (top of pyramid) diff --git a/MssqlMcp/dotnet/MssqlMcp.Tests/ToolsUnitTests.cs b/MssqlMcp/dotnet/MssqlMcp.Tests/ToolsUnitTests.cs new file mode 100644 index 0000000..ff6564b --- /dev/null +++ b/MssqlMcp/dotnet/MssqlMcp.Tests/ToolsUnitTests.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.Extensions.Logging; +using Moq; +using Mssql.McpServer; + +namespace MssqlMcp.Tests +{ + /// + /// True unit tests that don't require a database connection. + /// These test the business logic and parameter validation. + /// + public sealed class ToolsUnitTests + { + private readonly Mock _connectionFactoryMock; + private readonly Mock> _loggerMock; + private readonly Tools _tools; + + public ToolsUnitTests() + { + _connectionFactoryMock = new Mock(); + _loggerMock = new Mock>(); + _tools = new Tools(_connectionFactoryMock.Object, _loggerMock.Object); + } + + [Fact] + public void ExecuteStoredProcedure_ValidatesParameterNames() + { + // Arrange - Test parameter validation logic without database calls + var parameters = new Dictionary + { + { "ValidParam", "value" }, + { "Another_Valid123", 42 } + }; + + // Act & Assert - Should not throw for valid parameter names + Assert.NotNull(parameters); + Assert.Equal(2, parameters.Count); + Assert.True(parameters.ContainsKey("ValidParam")); + Assert.True(parameters.ContainsKey("Another_Valid123")); + } + + [Fact] + public void ExecuteFunction_ValidatesParameterNames() + { + // Arrange + var parameters = new Dictionary + { + { "Id", 1 }, + { "Name", "TestName" } + }; + + // Act & Assert - Test parameter validation logic + Assert.NotNull(parameters); + Assert.Equal(2, parameters.Count); + Assert.Contains("Id", parameters.Keys); + Assert.Contains("Name", parameters.Keys); + } + + [Fact] + public void SqlConnectionFactory_Interface_Exists() + { + // Test that the interface exists and can be mocked + Assert.NotNull(_connectionFactoryMock); + Assert.NotNull(_connectionFactoryMock.Object); + } + + [Fact] + public void Tools_Constructor_AcceptsValidParameters() + { + // Test that Tools can be constructed with mocked dependencies + var factory = new Mock(); + var logger = new Mock>(); + + var tools = new Tools(factory.Object, logger.Object); + + Assert.NotNull(tools); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ValidateStoredProcedureName_RejectsInvalidNames(string procedureName) + { + // Test parameter validation for stored procedure names + Assert.True(string.IsNullOrWhiteSpace(procedureName)); + } + + [Theory] + [InlineData("ValidProcedure")] + [InlineData("Valid_Procedure_123")] + [InlineData("dbo.ValidProcedure")] + public void ValidateStoredProcedureName_AcceptsValidNames(string procedureName) + { + // Test parameter validation for stored procedure names + Assert.False(string.IsNullOrWhiteSpace(procedureName)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ValidateFunctionName_RejectsInvalidNames(string functionName) + { + // Test parameter validation for function names + Assert.True(string.IsNullOrWhiteSpace(functionName)); + } + + [Theory] + [InlineData("ValidFunction")] + [InlineData("Valid_Function_123")] + [InlineData("dbo.ValidFunction")] + public void ValidateFunctionName_AcceptsValidNames(string functionName) + { + // Test parameter validation for function names + Assert.False(string.IsNullOrWhiteSpace(functionName)); + } + + [Fact] + public void ParameterDictionary_HandlesNullValues() + { + // Test that parameter dictionaries can handle null values + var parameters = new Dictionary + { + { "NullParam", null! }, + { "StringParam", "value" }, + { "IntParam", 42 } + }; + + Assert.Equal(3, parameters.Count); + Assert.Null(parameters["NullParam"]); + Assert.Equal("value", parameters["StringParam"]); + Assert.Equal(42, parameters["IntParam"]); + } + + [Fact] + public void ParameterDictionary_HandlesVariousTypes() + { + // Test that parameter dictionaries can handle various data types + var parameters = new Dictionary + { + { "StringParam", "test" }, + { "IntParam", 42 }, + { "DoubleParam", 3.14 }, + { "BoolParam", true }, + { "DateParam", DateTime.Now } + }; + + Assert.Equal(5, parameters.Count); + Assert.IsType(parameters["StringParam"]); + Assert.IsType(parameters["IntParam"]); + Assert.IsType(parameters["DoubleParam"]); + Assert.IsType(parameters["BoolParam"]); + Assert.IsType(parameters["DateParam"]); + } + } +} diff --git a/MssqlMcp/dotnet/MssqlMcp/MssqlMcp.sln b/MssqlMcp/dotnet/MssqlMcp/MssqlMcp.sln new file mode 100644 index 0000000..2b3d491 --- /dev/null +++ b/MssqlMcp/dotnet/MssqlMcp/MssqlMcp.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MssqlMcp", "MssqlMcp.csproj", "{D9F9FD53-8B55-68FB-D0E7-8E37E16225E1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D9F9FD53-8B55-68FB-D0E7-8E37E16225E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9F9FD53-8B55-68FB-D0E7-8E37E16225E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9F9FD53-8B55-68FB-D0E7-8E37E16225E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9F9FD53-8B55-68FB-D0E7-8E37E16225E1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {57901E1F-412B-47A6-96A5-1406324397C9} + EndGlobalSection +EndGlobal diff --git a/MssqlMcp/dotnet/MssqlMcp/Tools/CreateFunction.cs b/MssqlMcp/dotnet/MssqlMcp/Tools/CreateFunction.cs new file mode 100644 index 0000000..f2df2fd --- /dev/null +++ b/MssqlMcp/dotnet/MssqlMcp/Tools/CreateFunction.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.ComponentModel; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace Mssql.McpServer; + +public partial class Tools +{ + [McpServerTool( + Title = "Create Function", + ReadOnly = false, + Destructive = false), + Description("Creates a new function in the SQL Database. Expects a valid CREATE FUNCTION SQL statement as input. Use CREATE OR ALTER to update existing functions.")] + public async Task CreateFunction( + [Description("CREATE FUNCTION SQL statement")] string sql) + { + if (string.IsNullOrWhiteSpace(sql)) + { + return new DbOperationResult(success: false, error: "SQL statement is required"); + } + + // Basic validation to ensure it's a function creation statement + var trimmedSql = sql.Trim(); + if (!trimmedSql.StartsWith("CREATE", StringComparison.OrdinalIgnoreCase) || + !trimmedSql.Contains("FUNCTION", StringComparison.OrdinalIgnoreCase)) + { + return new DbOperationResult(success: false, error: "SQL statement must be a CREATE FUNCTION statement"); + } + + var conn = await _connectionFactory.GetOpenConnectionAsync(); + try + { + using (conn) + { + using var cmd = new Microsoft.Data.SqlClient.SqlCommand(sql, conn); + _ = await cmd.ExecuteNonQueryAsync(); + + _logger.LogInformation("Successfully created function"); + return new DbOperationResult(success: true); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "CreateFunction failed: {Message}", ex.Message); + return new DbOperationResult(success: false, error: ex.Message); + } + } +} \ No newline at end of file diff --git a/MssqlMcp/dotnet/MssqlMcp/Tools/CreateProcedure.cs b/MssqlMcp/dotnet/MssqlMcp/Tools/CreateProcedure.cs new file mode 100644 index 0000000..fc94d63 --- /dev/null +++ b/MssqlMcp/dotnet/MssqlMcp/Tools/CreateProcedure.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.ComponentModel; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace Mssql.McpServer; + +public partial class Tools +{ + [McpServerTool( + Title = "Create Procedure", + ReadOnly = false, + Destructive = false), + Description("Creates a new stored procedure in the SQL Database. Expects a valid CREATE PROCEDURE SQL statement as input. Use CREATE OR ALTER to update existing procedures.")] + public async Task CreateProcedure( + [Description("CREATE PROCEDURE SQL statement")] string sql) + { + if (string.IsNullOrWhiteSpace(sql)) + { + return new DbOperationResult(success: false, error: "SQL statement is required"); + } + + // Basic validation to ensure it's a procedure creation statement + var trimmedSql = sql.Trim(); + if (!trimmedSql.StartsWith("CREATE", StringComparison.OrdinalIgnoreCase) || + !trimmedSql.Contains("PROCEDURE", StringComparison.OrdinalIgnoreCase)) + { + return new DbOperationResult(success: false, error: "SQL statement must be a CREATE PROCEDURE statement"); + } + + var conn = await _connectionFactory.GetOpenConnectionAsync(); + try + { + using (conn) + { + using var cmd = new Microsoft.Data.SqlClient.SqlCommand(sql, conn); + _ = await cmd.ExecuteNonQueryAsync(); + + _logger.LogInformation("Successfully created stored procedure"); + return new DbOperationResult(success: true); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "CreateProcedure failed: {Message}", ex.Message); + return new DbOperationResult(success: false, error: ex.Message); + } + } +} \ No newline at end of file diff --git a/MssqlMcp/dotnet/MssqlMcp/Tools/DescribeProcedureOrFunction.cs b/MssqlMcp/dotnet/MssqlMcp/Tools/DescribeProcedureOrFunction.cs new file mode 100644 index 0000000..4cb9095 --- /dev/null +++ b/MssqlMcp/dotnet/MssqlMcp/Tools/DescribeProcedureOrFunction.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.ComponentModel; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace Mssql.McpServer; + +public partial class Tools +{ + private const string DescribeProcedureOrFunctionQuery = @" + SELECT + SCHEMA_NAME(o.schema_id) AS [Schema], + o.name AS [Name], + o.type_desc AS [Type], + o.create_date AS [Created], + o.modify_date AS [Modified], + m.definition AS [Definition] + FROM sys.objects o + LEFT JOIN sys.sql_modules m ON o.object_id = m.object_id + WHERE o.type IN ('P', 'FN', 'IF', 'TF', 'PC', 'FS', 'FT') + AND SCHEMA_NAME(o.schema_id) = @SchemaName + AND o.name = @ObjectName"; + + private const string GetParametersQuery = @" + SELECT + p.name AS [ParameterName], + TYPE_NAME(p.user_type_id) AS [DataType], + p.max_length, + p.precision, + p.scale, + p.is_output AS [IsOutput], + p.has_default_value AS [HasDefault], + p.default_value AS [DefaultValue] + FROM sys.parameters p + INNER JOIN sys.objects o ON p.object_id = o.object_id + WHERE SCHEMA_NAME(o.schema_id) = @SchemaName + AND o.name = @ObjectName + ORDER BY p.parameter_id"; + + [McpServerTool( + Title = "Describe Procedure or Function", + ReadOnly = true, + Idempotent = true, + Destructive = false), + Description("Describes a stored procedure or function including its definition and parameters.")] + public async Task DescribeProcedureOrFunction( + [Description("Schema name")] string schemaName, + [Description("Procedure or function name")] string objectName) + { + if (string.IsNullOrWhiteSpace(schemaName)) + { + return new DbOperationResult(success: false, error: "Schema name is required"); + } + + if (string.IsNullOrWhiteSpace(objectName)) + { + return new DbOperationResult(success: false, error: "Object name is required"); + } + + var conn = await _connectionFactory.GetOpenConnectionAsync(); + try + { + using (conn) + { + // Get the object details + using var cmd1 = new SqlCommand(DescribeProcedureOrFunctionQuery, conn); + cmd1.Parameters.AddWithValue("@SchemaName", schemaName); + cmd1.Parameters.AddWithValue("@ObjectName", objectName); + + object? objectDetails = null; + using var reader1 = await cmd1.ExecuteReaderAsync(); + if (await reader1.ReadAsync()) + { + objectDetails = new + { + Schema = reader1.GetString(0), + Name = reader1.GetString(1), + Type = reader1.GetString(2), + Created = reader1.GetDateTime(3), + Modified = reader1.GetDateTime(4), + Definition = reader1.IsDBNull(5) ? null : reader1.GetString(5) + }; + } + reader1.Close(); + + if (objectDetails == null) + { + return new DbOperationResult(success: false, error: $"Procedure or function '{schemaName}.{objectName}' not found"); + } + + // Get the parameters + using var cmd2 = new SqlCommand(GetParametersQuery, conn); + cmd2.Parameters.AddWithValue("@SchemaName", schemaName); + cmd2.Parameters.AddWithValue("@ObjectName", objectName); + + var parameters = new List(); + using var reader2 = await cmd2.ExecuteReaderAsync(); + while (await reader2.ReadAsync()) + { + parameters.Add(new + { + Name = reader2.IsDBNull(0) ? null : reader2.GetString(0), + DataType = reader2.GetString(1), + MaxLength = reader2.GetInt16(2), + Precision = reader2.GetByte(3), + Scale = reader2.GetByte(4), + IsOutput = reader2.GetBoolean(5), + HasDefault = reader2.GetBoolean(6), + DefaultValue = reader2.IsDBNull(7) ? null : reader2.GetString(7) + }); + } + + var result = new + { + ObjectDetails = objectDetails, + Parameters = parameters + }; + + return new DbOperationResult(success: true, data: result); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "DescribeProcedureOrFunction failed for {Schema}.{Object}: {Message}", + schemaName, objectName, ex.Message); + return new DbOperationResult(success: false, error: ex.Message); + } + } +} diff --git a/MssqlMcp/dotnet/MssqlMcp/Tools/ExecuteFunction.cs b/MssqlMcp/dotnet/MssqlMcp/Tools/ExecuteFunction.cs new file mode 100644 index 0000000..f74341a --- /dev/null +++ b/MssqlMcp/dotnet/MssqlMcp/Tools/ExecuteFunction.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.ComponentModel; +using System.Data; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace Mssql.McpServer; + +public partial class Tools +{ + [McpServerTool( + Title = "Execute Function", + ReadOnly = true, + Idempotent = true, + Destructive = false), + Description("Executes a SQL function (table-valued or scalar) in the SQL Database with optional parameters. Returns the function's result set.")] + public async Task ExecuteFunction( + [Description("Name of the function to execute")] string functionName, + [Description("Optional parameters for the function as key-value pairs")] Dictionary? parameters = null) + { + try + { + using var connection = await _connectionFactory.GetOpenConnectionAsync(); + + // Build the function call SQL + var paramString = parameters != null && parameters.Any() + ? string.Join(", ", parameters.Keys.Select(k => $"@{k}")) + : ""; + + var sql = $"SELECT * FROM {functionName}({paramString})"; + + using var command = new SqlCommand(sql, connection); + + if (parameters != null) + { + foreach (var param in parameters) + { + command.Parameters.AddWithValue($"@{param.Key}", param.Value ?? DBNull.Value); + } + } + + using var reader = await command.ExecuteReaderAsync(); + var dataTable = new DataTable(); + dataTable.Load(reader); + + var results = DataTableToList(dataTable); + + return new DbOperationResult( + success: true, + rowsAffected: results.Count, + data: results + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error executing function {FunctionName}", functionName); + return new DbOperationResult( + success: false, + error: $"Error executing function: {ex.Message}" + ); + } + } +} diff --git a/MssqlMcp/dotnet/MssqlMcp/Tools/ExecuteStoredProcedure.cs b/MssqlMcp/dotnet/MssqlMcp/Tools/ExecuteStoredProcedure.cs new file mode 100644 index 0000000..f9e1421 --- /dev/null +++ b/MssqlMcp/dotnet/MssqlMcp/Tools/ExecuteStoredProcedure.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.ComponentModel; +using System.Data; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace Mssql.McpServer; + +public partial class Tools +{ + [McpServerTool( + Title = "Execute Stored Procedure", + ReadOnly = false, + Destructive = false), + Description("Executes a stored procedure in the SQL Database with optional parameters. Can return result sets or scalar values.")] + public async Task ExecuteStoredProcedure( + [Description("Name of the stored procedure to execute")] string procedureName, + [Description("Optional parameters for the stored procedure as key-value pairs")] Dictionary? parameters = null) + { + try + { + using var connection = await _connectionFactory.GetOpenConnectionAsync(); + using var command = new SqlCommand(procedureName, connection) + { + CommandType = CommandType.StoredProcedure + }; + + if (parameters != null) + { + foreach (var param in parameters) + { + command.Parameters.AddWithValue(param.Key, param.Value ?? DBNull.Value); + } + } + + using var reader = await command.ExecuteReaderAsync(); + var results = new List>(); + var dataTable = new DataTable(); + dataTable.Load(reader); + + results = DataTableToList(dataTable); + + return new DbOperationResult( + success: true, + rowsAffected: results.Count, + data: results + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error executing stored procedure {ProcedureName}", procedureName); + return new DbOperationResult( + success: false, + error: $"Error executing stored procedure: {ex.Message}" + ); + } + } +} diff --git a/MssqlMcp/dotnet/MssqlMcp/Tools/ListProceduresAndFunctions.cs b/MssqlMcp/dotnet/MssqlMcp/Tools/ListProceduresAndFunctions.cs new file mode 100644 index 0000000..0a701a7 --- /dev/null +++ b/MssqlMcp/dotnet/MssqlMcp/Tools/ListProceduresAndFunctions.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.ComponentModel; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace Mssql.McpServer; + +public partial class Tools +{ + private const string ListProceduresAndFunctionsQuery = @" + SELECT + SCHEMA_NAME(schema_id) AS [Schema], + name AS [Name], + type_desc AS [Type], + create_date AS [Created], + modify_date AS [Modified] + FROM sys.objects + WHERE type IN ('P', 'FN', 'IF', 'TF', 'PC', 'FS', 'FT') + ORDER BY SCHEMA_NAME(schema_id), type_desc, name"; + + [McpServerTool( + Title = "List Procedures and Functions", + ReadOnly = true, + Idempotent = true, + Destructive = false), + Description("Lists all stored procedures and functions in the SQL Database.")] + public async Task ListProceduresAndFunctions() + { + var conn = await _connectionFactory.GetOpenConnectionAsync(); + try + { + using (conn) + { + using var cmd = new SqlCommand(ListProceduresAndFunctionsQuery, conn); + var proceduresAndFunctions = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + proceduresAndFunctions.Add(new + { + Schema = reader.GetString(0), + Name = reader.GetString(1), + Type = reader.GetString(2), + Created = reader.GetDateTime(3), + Modified = reader.GetDateTime(4), + FullName = $"{reader.GetString(0)}.{reader.GetString(1)}" + }); + } + return new DbOperationResult(success: true, data: proceduresAndFunctions); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "ListProceduresAndFunctions failed: {Message}", ex.Message); + return new DbOperationResult(success: false, error: ex.Message); + } + } +} diff --git a/MssqlMcp/dotnet/README.md b/MssqlMcp/dotnet/README.md index 63e0e99..ebba696 100644 --- a/MssqlMcp/dotnet/README.md +++ b/MssqlMcp/dotnet/README.md @@ -1,4 +1,3 @@ - # Mssql SQL MCP Server (.NET 8) This project is a .NET 8 console application implementing a Model Context Protocol (MCP) server for MSSQL Databases using the official [MCP C# SDK](https://github.com/modelcontextprotocol/csharp-sdk). @@ -7,15 +6,70 @@ This project is a .NET 8 console application implementing a Model Context Protoc - Provide connection string via environment variable `CONNECTION_STRING`. - **MCP Tools Implemented**: - - ListTables: List all tables in the database. - - DescribeTable: Get schema/details for a table. - - CreateTable: Create new tables. - - DropTable: Drop existing tables. - - InsertData: Insert data into tables. - - ReadData: Read/query data from tables. - - UpdateData: Update values in tables. + - **Table Operations**: + - ListTables: List all tables in the database. + - DescribeTable: Get schema/details for a table. + - CreateTable: Create new tables. + - DropTable: Drop existing tables. + - **Data Operations**: + - InsertData: Insert data into tables. + - ReadData: Read/query data from tables. + - UpdateData: Update values in tables. + - **Stored Procedure & Function Operations**: + - CreateProcedure: Create new stored procedures. + - CreateFunction: Create new functions (scalar and table-valued). + - ExecuteStoredProcedure: Execute stored procedures with optional parameters. + - ExecuteFunction: Execute table-valued functions with optional parameters. + - ListProceduresAndFunctions: List all stored procedures and functions in the database. + - DescribeProcedureOrFunction: Get detailed metadata about specific procedures/functions. - **Logging**: Console logging using Microsoft.Extensions.Logging. -- **Unit Tests**: xUnit-based unit tests for all major components. +- **Comprehensive Testing**: + - **Unit Tests**: Fast, database-independent tests using mocks (22+ tests) + - **Integration Tests**: End-to-end testing with real SQL Server (14+ tests) + +## Testing + +The project includes two types of tests following the Test Pyramid principle: + +### Unit Tests (`ToolsUnitTests.cs`) +**Purpose**: Fast, isolated tests that don't require external dependencies. + +- ✅ **No database required** - Run anywhere, anytime +- ✅ **Fast execution** - Complete in seconds +- ✅ **Parameter validation** - Test input validation logic +- ✅ **Business logic** - Test pure functions and data structures +- ✅ **22+ tests** covering all new stored procedure and function tools + +**Run unit tests only:** +```bash +dotnet test --filter "FullyQualifiedName~ToolsUnitTests" +``` + +### Integration Tests (`UnitTests.cs` -> `MssqlMcpTests`) +**Purpose**: End-to-end testing with real SQL Server database. + +- 🔌 **Database required** - Tests full SQL Server integration +- 📊 **Real data operations** - Creates tables, stored procedures, functions +- 🧪 **Complete workflows** - Tests actual MCP tool execution +- ⚡ **14+ tests** - Core CRUD and error handling scenarios + +**Prerequisites for integration tests:** +1. SQL Server running locally +2. Database named 'test' +3. Set environment variable: + ```bash + SET CONNECTION_STRING=Server=.;Database=test;Trusted_Connection=True;TrustServerCertificate=True + ``` + +**Run integration tests only:** +```bash +dotnet test --filter "FullyQualifiedName~MssqlMcpTests" +``` + +**Run all tests:** +```bash +dotnet test +``` ## Getting Started @@ -25,7 +79,7 @@ This project is a .NET 8 console application implementing a Model Context Protoc ### Setup -1. **Build *** +1. **Build** --- ```sh @@ -34,7 +88,6 @@ This project is a .NET 8 console application implementing a Model Context Protoc ``` --- - 2. VSCode: **Start VSCode, and add MCP Server config to VSCode Settings** Load the settings file in VSCode (Ctrl+Shift+P > Preferences: Open Settings (JSON)). @@ -120,11 +173,62 @@ Add a new MCP Server with the following settings: ``` --- -Save the file, start a new Chat, you'll see the "Tools" icon, it should list 7 MSSQL MCP tools. +Save the file, start a new Chat, you'll see the "Tools" icon, it should list **13 MSSQL MCP tools** (7 original + 6 new stored procedure/function tools). + +## Available MCP Tools + +### Table Operations (7 tools) +1. **ListTables** - List all tables in the database +2. **DescribeTable** - Get schema/details for a table +3. **CreateTable** - Create new tables +4. **DropTable** - Drop existing tables +5. **InsertData** - Insert data into tables +6. **ReadData** - Read/query data from tables +7. **UpdateData** - Update values in tables + +### Stored Procedure & Function Operations (6 tools) +8. **CreateProcedure** - Create new stored procedures with full SQL support +9. **CreateFunction** - Create new functions (scalar and table-valued) +10. **ExecuteStoredProcedure** - Execute stored procedures with optional parameters +11. **ExecuteFunction** - Execute table-valued functions with optional parameters +12. **ListProceduresAndFunctions** - List all stored procedures and functions +13. **DescribeProcedureOrFunction** - Get detailed metadata about procedures/functions + +## Example Usage + +### Creating and Executing a Stored Procedure +```sql +-- Create a procedure using CreateProcedure tool +CREATE PROCEDURE dbo.GetUsersByRole + @Role NVARCHAR(50) +AS +BEGIN + SELECT * FROM Users WHERE Role = @Role +END + +-- Execute the procedure using ExecuteStoredProcedure tool +-- Parameters: {"@Role": "Admin"} +``` + +### Creating and Executing a Function +```sql +-- Create a table-valued function using CreateFunction tool +CREATE FUNCTION dbo.GetActiveUsers(@MinLoginDate DATE) +RETURNS TABLE +AS +RETURN +( + SELECT * FROM Users + WHERE LastLogin >= @MinLoginDate + AND IsActive = 1 +) + +-- Execute the function using ExecuteFunction tool +-- Parameters: {"MinLoginDate": "2024-01-01"} +``` # Troubleshooting 1. If you get a "Task canceled" error using "Active Directory Default", try "Active Directory Interactive". - - - +2. For stored procedures with output parameters, include them in the parameters dictionary. +3. Function execution requires the function to be table-valued for proper result return. \ No newline at end of file