Skip to content
68 changes: 68 additions & 0 deletions MssqlMcp/dotnet/MssqlMcp.Tests/README.md
Original file line number Diff line number Diff line change
@@ -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)
157 changes: 157 additions & 0 deletions MssqlMcp/dotnet/MssqlMcp.Tests/ToolsUnitTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// True unit tests that don't require a database connection.
/// These test the business logic and parameter validation.
/// </summary>
public sealed class ToolsUnitTests
{
private readonly Mock<ISqlConnectionFactory> _connectionFactoryMock;
private readonly Mock<ILogger<Tools>> _loggerMock;
private readonly Tools _tools;

public ToolsUnitTests()
{
_connectionFactoryMock = new Mock<ISqlConnectionFactory>();
_loggerMock = new Mock<ILogger<Tools>>();
_tools = new Tools(_connectionFactoryMock.Object, _loggerMock.Object);
}

[Fact]
public void ExecuteStoredProcedure_ValidatesParameterNames()
{
// Arrange - Test parameter validation logic without database calls
var parameters = new Dictionary<string, object>
{
{ "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<string, object>
{
{ "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<ISqlConnectionFactory>();
var logger = new Mock<ILogger<Tools>>();

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<string, object>
{
{ "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<string, object>
{
{ "StringParam", "test" },
{ "IntParam", 42 },
{ "DoubleParam", 3.14 },
{ "BoolParam", true },
{ "DateParam", DateTime.Now }
};

Assert.Equal(5, parameters.Count);
Assert.IsType<string>(parameters["StringParam"]);
Assert.IsType<int>(parameters["IntParam"]);
Assert.IsType<double>(parameters["DoubleParam"]);
Assert.IsType<bool>(parameters["BoolParam"]);
Assert.IsType<DateTime>(parameters["DateParam"]);
}
}
}
24 changes: 24 additions & 0 deletions MssqlMcp/dotnet/MssqlMcp/MssqlMcp.sln
Original file line number Diff line number Diff line change
@@ -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
132 changes: 132 additions & 0 deletions MssqlMcp/dotnet/MssqlMcp/Tools/DescribeProcedureOrFunction.cs
Original file line number Diff line number Diff line change
@@ -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<DbOperationResult> 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<object>();
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);
}
}
}
Loading