feat(backend): Implement MCP Protocol Handler (Story 5.1)

Implemented JSON-RPC 2.0 protocol handler for MCP communication, enabling AI agents to communicate with ColaFlow using the Model Context Protocol.

**Implementation:**
- JSON-RPC 2.0 data models (Request, Response, Error, ErrorCode)
- MCP protocol models (Initialize, Capabilities, ClientInfo, ServerInfo)
- McpProtocolHandler with method routing and error handling
- Method handlers: initialize, resources/list, tools/list, tools/call
- ASP.NET Core middleware for /mcp endpoint
- Service registration and dependency injection setup

**Testing:**
- 28 unit tests covering protocol parsing, validation, and error handling
- Integration tests for initialize handshake and error responses
- All tests passing with >80% coverage

**Changes:**
- Created ColaFlow.Modules.Mcp.Contracts project
- Created ColaFlow.Modules.Mcp.Domain project
- Created ColaFlow.Modules.Mcp.Application project
- Created ColaFlow.Modules.Mcp.Infrastructure project
- Created ColaFlow.Modules.Mcp.Tests project
- Registered MCP module in ColaFlow.API Program.cs
- Added /mcp endpoint via middleware

**Acceptance Criteria Met:**
 JSON-RPC 2.0 messages correctly parsed
 Request validation (jsonrpc: "2.0", method, params, id)
 Error responses conform to JSON-RPC 2.0 spec
 Invalid requests return proper error codes (-32700, -32600, -32601, -32602)
 MCP initialize method implemented
 Server capabilities returned (resources, tools, prompts)
 Protocol version negotiation works (1.0)
 Request routing to method handlers
 Unit test coverage > 80%
 All tests passing

**Story**: docs/stories/sprint_5/story_5_1.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-07 19:38:34 +01:00
parent d3ef2c1441
commit 48a8431e4f
43 changed files with 7003 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Modules\Mcp\ColaFlow.Modules.Mcp.Application\ColaFlow.Modules.Mcp.Application.csproj" />
<ProjectReference Include="..\..\..\..\src\Modules\Mcp\ColaFlow.Modules.Mcp.Infrastructure\ColaFlow.Modules.Mcp.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,160 @@
using System.Text.Json;
using ColaFlow.Modules.Mcp.Contracts.JsonRpc;
using FluentAssertions;
namespace ColaFlow.Modules.Mcp.Tests.Contracts;
/// <summary>
/// Unit tests for JsonRpcRequest
/// </summary>
public class JsonRpcRequestTests
{
[Fact]
public void IsValid_WithValidRequest_ReturnsTrue()
{
// Arrange
var request = new JsonRpcRequest
{
JsonRpc = "2.0",
Method = "test_method",
Id = 1
};
// Act
var isValid = request.IsValid(out var errorMessage);
// Assert
isValid.Should().BeTrue();
errorMessage.Should().BeNull();
}
[Fact]
public void IsValid_WithInvalidJsonRpcVersion_ReturnsFalse()
{
// Arrange
var request = new JsonRpcRequest
{
JsonRpc = "1.0",
Method = "test_method"
};
// Act
var isValid = request.IsValid(out var errorMessage);
// Assert
isValid.Should().BeFalse();
errorMessage.Should().Contain("jsonrpc must be exactly '2.0'");
}
[Fact]
public void IsValid_WithEmptyMethod_ReturnsFalse()
{
// Arrange
var request = new JsonRpcRequest
{
JsonRpc = "2.0",
Method = ""
};
// Act
var isValid = request.IsValid(out var errorMessage);
// Assert
isValid.Should().BeFalse();
errorMessage.Should().Contain("method is required");
}
[Fact]
public void IsNotification_WithNoId_ReturnsTrue()
{
// Arrange
var request = new JsonRpcRequest
{
JsonRpc = "2.0",
Method = "notification_method",
Id = null
};
// Act & Assert
request.IsNotification.Should().BeTrue();
}
[Fact]
public void IsNotification_WithId_ReturnsFalse()
{
// Arrange
var request = new JsonRpcRequest
{
JsonRpc = "2.0",
Method = "regular_method",
Id = 1
};
// Act & Assert
request.IsNotification.Should().BeFalse();
}
[Fact]
public void Deserialize_WithValidJson_CreatesRequest()
{
// Arrange
var json = @"{
""jsonrpc"": ""2.0"",
""method"": ""test_method"",
""params"": { ""key"": ""value"" },
""id"": 1
}";
// Act
var request = JsonSerializer.Deserialize<JsonRpcRequest>(json);
// Assert
request.Should().NotBeNull();
request!.JsonRpc.Should().Be("2.0");
request.Method.Should().Be("test_method");
request.Params.Should().NotBeNull();
// Id can be number or string, System.Text.Json deserializes number to JsonElement
// Just check it's not null
request.Id.Should().NotBeNull();
}
[Fact]
public void Deserialize_WithoutParams_CreatesRequestWithNullParams()
{
// Arrange
var json = @"{
""jsonrpc"": ""2.0"",
""method"": ""test_method"",
""id"": 1
}";
// Act
var request = JsonSerializer.Deserialize<JsonRpcRequest>(json);
// Assert
request.Should().NotBeNull();
request!.Params.Should().BeNull();
}
[Fact]
public void Serialize_IncludesAllFields()
{
// Arrange
var request = new JsonRpcRequest
{
JsonRpc = "2.0",
Method = "test_method",
Params = new { key = "value" },
Id = 1
};
// Act
var json = JsonSerializer.Serialize(request);
// Assert
json.Should().Contain("\"jsonrpc\":\"2.0\"");
json.Should().Contain("\"method\":\"test_method\"");
json.Should().Contain("\"params\":");
json.Should().Contain("\"id\":1");
}
}

View File

@@ -0,0 +1,106 @@
using System.Text.Json;
using ColaFlow.Modules.Mcp.Contracts.JsonRpc;
using FluentAssertions;
namespace ColaFlow.Modules.Mcp.Tests.Contracts;
/// <summary>
/// Unit tests for JsonRpcResponse
/// </summary>
public class JsonRpcResponseTests
{
[Fact]
public void Success_CreatesValidSuccessResponse()
{
// Arrange
var result = new { message = "success" };
var id = 1;
// Act
var response = JsonRpcResponse.Success(result, id);
// Assert
response.Should().NotBeNull();
response.JsonRpc.Should().Be("2.0");
response.Result.Should().NotBeNull();
response.Error.Should().BeNull();
response.Id.Should().Be(id);
}
[Fact]
public void CreateError_CreatesValidErrorResponse()
{
// Arrange
var error = JsonRpcError.InternalError("Test error");
var id = 1;
// Act
var response = JsonRpcResponse.CreateError(error, id);
// Assert
response.Should().NotBeNull();
response.JsonRpc.Should().Be("2.0");
response.Result.Should().BeNull();
response.Error.Should().NotBeNull();
response.Error.Should().Be(error);
response.Id.Should().Be(id);
}
[Fact]
public void ParseError_CreatesResponseWithNullId()
{
// Act
var response = JsonRpcResponse.ParseError("Invalid JSON");
// Assert
response.Error.Should().NotBeNull();
response.Error!.Code.Should().Be((int)JsonRpcErrorCode.ParseError);
response.Id.Should().BeNull();
}
[Fact]
public void MethodNotFound_IncludesMethodNameInError()
{
// Act
var response = JsonRpcResponse.MethodNotFound("unknown_method", 1);
// Assert
response.Error.Should().NotBeNull();
response.Error!.Code.Should().Be((int)JsonRpcErrorCode.MethodNotFound);
response.Error.Message.Should().Contain("unknown_method");
}
[Fact]
public void Serialize_SuccessResponse_DoesNotIncludeError()
{
// Arrange
var response = JsonRpcResponse.Success(new { result = "ok" }, 1);
// Act
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});
// Assert
json.Should().Contain("\"result\":");
json.Should().NotContain("\"error\":");
}
[Fact]
public void Serialize_ErrorResponse_DoesNotIncludeResult()
{
// Arrange
var response = JsonRpcResponse.CreateError(JsonRpcError.InternalError(), 1);
// Act
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});
// Assert
json.Should().Contain("\"error\":");
json.Should().NotContain("\"result\":");
}
}

View File

@@ -0,0 +1,135 @@
using System.Text.Json;
using ColaFlow.Modules.Mcp.Application.Handlers;
using ColaFlow.Modules.Mcp.Contracts.Mcp;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace ColaFlow.Modules.Mcp.Tests.Handlers;
/// <summary>
/// Unit tests for InitializeMethodHandler
/// </summary>
public class InitializeMethodHandlerTests
{
private readonly ILogger<InitializeMethodHandler> _logger;
private readonly InitializeMethodHandler _sut;
public InitializeMethodHandlerTests()
{
_logger = Substitute.For<ILogger<InitializeMethodHandler>>();
_sut = new InitializeMethodHandler(_logger);
}
[Fact]
public void MethodName_ReturnsInitialize()
{
// Assert
_sut.MethodName.Should().Be("initialize");
}
[Fact]
public async Task HandleAsync_WithValidRequest_ReturnsInitializeResponse()
{
// Arrange
var initRequest = new McpInitializeRequest
{
ProtocolVersion = "1.0",
ClientInfo = new McpClientInfo
{
Name = "Claude Desktop",
Version = "1.0.0"
}
};
// Act
var result = await _sut.HandleAsync(initRequest, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Should().BeOfType<McpInitializeResponse>();
var response = (McpInitializeResponse)result!;
response.ProtocolVersion.Should().Be("1.0");
response.ServerInfo.Should().NotBeNull();
response.ServerInfo.Name.Should().Be("ColaFlow MCP Server");
response.ServerInfo.Version.Should().Be("1.0.0");
response.Capabilities.Should().NotBeNull();
}
[Fact]
public async Task HandleAsync_ReturnsCapabilitiesWithAllFeaturesSupported()
{
// Arrange
var initRequest = new McpInitializeRequest
{
ProtocolVersion = "1.0",
ClientInfo = new McpClientInfo { Name = "Test", Version = "1.0" }
};
// Act
var result = await _sut.HandleAsync(initRequest, CancellationToken.None);
// Assert
var response = (McpInitializeResponse)result!;
response.Capabilities.Resources.Should().NotBeNull();
response.Capabilities.Resources!.Supported.Should().BeTrue();
response.Capabilities.Tools.Should().NotBeNull();
response.Capabilities.Tools!.Supported.Should().BeTrue();
response.Capabilities.Prompts.Should().NotBeNull();
response.Capabilities.Prompts!.Supported.Should().BeTrue();
}
[Fact]
public async Task HandleAsync_WithNullParams_ReturnsValidResponse()
{
// Act
var result = await _sut.HandleAsync(null, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Should().BeOfType<McpInitializeResponse>();
}
[Fact]
public async Task HandleAsync_WithObjectParams_DeserializesCorrectly()
{
// Arrange - Simulate how JSON deserialization works with object params
var paramsObj = new
{
protocolVersion = "1.0",
clientInfo = new
{
name = "Claude Desktop",
version = "1.0.0"
}
};
// Act
var result = await _sut.HandleAsync(paramsObj, CancellationToken.None);
// Assert
result.Should().NotBeNull();
var response = (McpInitializeResponse)result!;
response.ProtocolVersion.Should().Be("1.0");
}
[Fact]
public async Task HandleAsync_WithUnsupportedProtocolVersion_StillReturnsResponse()
{
// Arrange
var initRequest = new McpInitializeRequest
{
ProtocolVersion = "2.0", // Unsupported version
ClientInfo = new McpClientInfo { Name = "Test", Version = "1.0" }
};
// Act
var result = await _sut.HandleAsync(initRequest, CancellationToken.None);
// Assert
result.Should().NotBeNull();
var response = (McpInitializeResponse)result!;
response.ProtocolVersion.Should().Be("1.0"); // Server returns its supported version
}
}

View File

@@ -0,0 +1,223 @@
using ColaFlow.Modules.Mcp.Application.Handlers;
using ColaFlow.Modules.Mcp.Contracts.JsonRpc;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace ColaFlow.Modules.Mcp.Tests.Handlers;
/// <summary>
/// Unit tests for McpProtocolHandler
/// </summary>
public class McpProtocolHandlerTests
{
private readonly ILogger<McpProtocolHandler> _logger;
private readonly List<IMcpMethodHandler> _methodHandlers;
private readonly McpProtocolHandler _sut;
public McpProtocolHandlerTests()
{
_logger = Substitute.For<ILogger<McpProtocolHandler>>();
_methodHandlers = new List<IMcpMethodHandler>();
_sut = new McpProtocolHandler(_logger, _methodHandlers);
}
[Fact]
public async Task HandleRequestAsync_WithInvalidJsonRpc_ReturnsInvalidRequest()
{
// Arrange
var request = new JsonRpcRequest
{
JsonRpc = "1.0", // Invalid version
Method = "test"
};
// Act
var response = await _sut.HandleRequestAsync(request, CancellationToken.None);
// Assert
response.Should().NotBeNull();
response.JsonRpc.Should().Be("2.0");
response.Error.Should().NotBeNull();
response.Error!.Code.Should().Be((int)JsonRpcErrorCode.InvalidRequest);
response.Result.Should().BeNull();
}
[Fact]
public async Task HandleRequestAsync_WithMissingMethod_ReturnsInvalidRequest()
{
// Arrange
var request = new JsonRpcRequest
{
JsonRpc = "2.0",
Method = "" // Empty method
};
// Act
var response = await _sut.HandleRequestAsync(request, CancellationToken.None);
// Assert
response.Should().NotBeNull();
response.Error.Should().NotBeNull();
response.Error!.Code.Should().Be((int)JsonRpcErrorCode.InvalidRequest);
}
[Fact]
public async Task HandleRequestAsync_WithUnknownMethod_ReturnsMethodNotFound()
{
// Arrange
var request = new JsonRpcRequest
{
JsonRpc = "2.0",
Method = "unknown_method",
Id = 1
};
// Act
var response = await _sut.HandleRequestAsync(request, CancellationToken.None);
// Assert
response.Should().NotBeNull();
response.JsonRpc.Should().Be("2.0");
response.Id.Should().Be(1);
response.Error.Should().NotBeNull();
response.Error!.Code.Should().Be((int)JsonRpcErrorCode.MethodNotFound);
response.Error.Message.Should().Contain("unknown_method");
response.Result.Should().BeNull();
}
[Fact]
public async Task HandleRequestAsync_WithValidMethod_ReturnsSuccess()
{
// Arrange
var mockHandler = Substitute.For<IMcpMethodHandler>();
mockHandler.MethodName.Returns("test_method");
mockHandler.HandleAsync(Arg.Any<object?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<object?>(new { result = "success" }));
var handler = new McpProtocolHandler(_logger, new[] { mockHandler });
var request = new JsonRpcRequest
{
JsonRpc = "2.0",
Method = "test_method",
Id = 1
};
// Act
var response = await handler.HandleRequestAsync(request, CancellationToken.None);
// Assert
response.Should().NotBeNull();
response.JsonRpc.Should().Be("2.0");
response.Id.Should().Be(1);
response.Error.Should().BeNull();
response.Result.Should().NotBeNull();
}
[Fact]
public async Task HandleRequestAsync_WhenHandlerThrowsArgumentException_ReturnsInvalidParams()
{
// Arrange
var mockHandler = Substitute.For<IMcpMethodHandler>();
mockHandler.MethodName.Returns("test_method");
mockHandler.HandleAsync(Arg.Any<object?>(), Arg.Any<CancellationToken>())
.Returns<object?>(_ => throw new ArgumentException("Invalid parameter"));
var handler = new McpProtocolHandler(_logger, new[] { mockHandler });
var request = new JsonRpcRequest
{
JsonRpc = "2.0",
Method = "test_method",
Id = 1
};
// Act
var response = await handler.HandleRequestAsync(request, CancellationToken.None);
// Assert
response.Should().NotBeNull();
response.Error.Should().NotBeNull();
response.Error!.Code.Should().Be((int)JsonRpcErrorCode.InvalidParams);
}
[Fact]
public async Task HandleRequestAsync_WhenHandlerThrowsException_ReturnsInternalError()
{
// Arrange
var mockHandler = Substitute.For<IMcpMethodHandler>();
mockHandler.MethodName.Returns("test_method");
mockHandler.HandleAsync(Arg.Any<object?>(), Arg.Any<CancellationToken>())
.Returns<object?>(_ => throw new InvalidOperationException("Something went wrong"));
var handler = new McpProtocolHandler(_logger, new[] { mockHandler });
var request = new JsonRpcRequest
{
JsonRpc = "2.0",
Method = "test_method",
Id = 1
};
// Act
var response = await handler.HandleRequestAsync(request, CancellationToken.None);
// Assert
response.Should().NotBeNull();
response.Error.Should().NotBeNull();
response.Error!.Code.Should().Be((int)JsonRpcErrorCode.InternalError);
}
[Fact]
public async Task HandleRequestAsync_WithStringId_PreservesIdInResponse()
{
// Arrange
var mockHandler = Substitute.For<IMcpMethodHandler>();
mockHandler.MethodName.Returns("test_method");
mockHandler.HandleAsync(Arg.Any<object?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<object?>(new { result = "success" }));
var handler = new McpProtocolHandler(_logger, new[] { mockHandler });
var request = new JsonRpcRequest
{
JsonRpc = "2.0",
Method = "test_method",
Id = "abc-123"
};
// Act
var response = await handler.HandleRequestAsync(request, CancellationToken.None);
// Assert
response.Id.Should().Be("abc-123");
}
[Fact]
public async Task HandleRequestAsync_WithParams_PassesParamsToHandler()
{
// Arrange
var mockHandler = Substitute.For<IMcpMethodHandler>();
mockHandler.MethodName.Returns("test_method");
mockHandler.HandleAsync(Arg.Any<object?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<object?>(new { result = "success" }));
var handler = new McpProtocolHandler(_logger, new[] { mockHandler });
var testParams = new { param1 = "value1", param2 = 42 };
var request = new JsonRpcRequest
{
JsonRpc = "2.0",
Method = "test_method",
Params = testParams,
Id = 1
};
// Act
await handler.HandleRequestAsync(request, CancellationToken.None);
// Assert
await mockHandler.Received(1).HandleAsync(testParams, Arg.Any<CancellationToken>());
}
}