diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/JsonRpcConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/JsonRpcConstants.cs new file mode 100644 index 0000000..36951a3 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/JsonRpcConstants.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Constants; + +/// +/// JSON-RPC 2.0 specification constants +/// +public static class JsonRpcConstants +{ + /// + /// JSON-RPC version string + /// + public const string Version = "2.0"; + + /// + /// JSON-RPC error codes per JSON-RPC 2.0 specification + /// See: https://www.jsonrpc.org/specification#error_object + /// + public static class ErrorCodes + { + /// + /// Invalid Request (-32600) - The JSON sent is not a valid Request object + /// + public const int InvalidRequest = -32600; + + /// + /// Method not found (-32601) - The method does not exist / is not available + /// + public const int MethodNotFound = -32601; + + /// + /// Invalid params (-32602) - Invalid method parameter(s) + /// + public const int InvalidParams = -32602; + + /// + /// Internal error (-32603) - Internal JSON-RPC error + /// + public const int InternalError = -32603; + } + + /// + /// HTTP status codes for MCP protocol + /// + public static class HttpStatusCodes + { + /// + /// Accepted (202) - Used for MCP notifications per Streamable HTTP spec + /// Notifications do not expect a response body + /// + public const int Accepted = 202; + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/Constants/JsonRpcConstants.cs b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/Constants/JsonRpcConstants.cs new file mode 100644 index 0000000..032097b --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/Constants/JsonRpcConstants.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.MockToolingServer.Constants; + +/// +/// JSON-RPC 2.0 specification constants +/// +public static class JsonRpcConstants +{ + /// + /// JSON-RPC version string + /// + public const string Version = "2.0"; + + /// + /// JSON-RPC error codes per JSON-RPC 2.0 specification + /// See: https://www.jsonrpc.org/specification#error_object + /// + public static class ErrorCodes + { + /// + /// Invalid Request (-32600) - The JSON sent is not a valid Request object + /// + public const int InvalidRequest = -32600; + + /// + /// Method not found (-32601) - The method does not exist / is not available + /// + public const int MethodNotFound = -32601; + + /// + /// Invalid params (-32602) - Invalid method parameter(s) + /// + public const int InvalidParams = -32602; + + /// + /// Internal error (-32603) - Internal JSON-RPC error + /// + public const int InternalError = -32603; + } + + /// + /// HTTP status codes for MCP protocol + /// + public static class HttpStatusCodes + { + /// + /// Accepted (202) - Used for MCP notifications per Streamable HTTP spec + /// Notifications do not expect a response body + /// + public const int Accepted = 202; + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/Server.cs b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/Server.cs index c1e0653..8765f89 100644 --- a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/Server.cs +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/Server.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.MockToolingServer.Constants; + namespace Microsoft.Agents.A365.DevTools.MockToolingServer; public static class Server @@ -87,11 +89,13 @@ public static async Task Start(string[] args) // JSON-RPC over HTTP for mock tools at /mcp-mock app.MapPost("/agents/servers/{mcpServerName}", async (string mcpServerName, HttpRequest httpRequest, IMockToolExecutor executor, ILogger log) => { + // Declare idValue outside try block so catch handler can preserve original request ID. + // This ensures error responses include the correct ID from the client's request. + object? idValue = null; try { using var doc = await JsonDocument.ParseAsync(httpRequest.Body); var root = doc.RootElement; - object? idValue = null; if (root.TryGetProperty("id", out var idProp)) { if (idProp.ValueKind == JsonValueKind.Number) @@ -108,6 +112,8 @@ public static async Task Start(string[] args) } } + // Validate that 'method' field exists and is a string (JSON-RPC 2.0 requirement). + // All subsequent code can safely assume 'method' is non-null after this check. if (!root.TryGetProperty("method", out var methodProp) || methodProp.ValueKind != JsonValueKind.String) { return Results.BadRequest(new { error = "Invalid or missing 'method' property." }); @@ -145,17 +151,35 @@ public static async Task Start(string[] args) }, instructions = "Optional instructions for the client" }; - return Results.Json(new { jsonrpc = "2.0", id = idValue, result = initializeResult }); + return Results.Json(new { jsonrpc = JsonRpcConstants.Version, id = idValue, result = initializeResult }); } if (string.Equals(method, "logging/setLevel", StringComparison.OrdinalIgnoreCase)) { // Acknowledge but do nothing - return Results.Json(new { jsonrpc = "2.0", id = idValue, result = new { } }); + return Results.Json(new { jsonrpc = JsonRpcConstants.Version, id = idValue, result = new { } }); } if (string.Equals(method, "tools/list", StringComparison.OrdinalIgnoreCase)) { - var listResult = await executor.ListToolsAsync(mcpServerName); - return Results.Json(new { jsonrpc = "2.0", id = idValue, result = listResult }); + try + { + var listResult = await executor.ListToolsAsync(mcpServerName); + return Results.Json(new { jsonrpc = JsonRpcConstants.Version, id = idValue, result = listResult }); + } + catch (ArgumentException ex) + { + // Unknown MCP server name - return JSON-RPC error (consistent with tools/call) + log.LogWarning(ex, "No mock tool store for '{McpServerName}' - returning error", mcpServerName); + return Results.Json(new + { + jsonrpc = JsonRpcConstants.Version, + id = idValue, + error = new + { + code = JsonRpcConstants.ErrorCodes.InvalidParams, + message = $"MCP server '{mcpServerName}' not found" + } + }); + } } if (string.Equals(method, "tools/call", StringComparison.OrdinalIgnoreCase)) { @@ -187,22 +211,81 @@ public static async Task Start(string[] args) argsDict[prop.Name] = converted; } } - var callResult = await executor.CallToolAsync(mcpServerName, name, argsDict!); - // Detect error shape - var errorProp = callResult.GetType().GetProperty("error"); - if (errorProp != null) + try { - return Results.Json(new { jsonrpc = "2.0", id = idValue, error = errorProp.GetValue(callResult) }); + var callResult = await executor.CallToolAsync(mcpServerName, name, argsDict!); + // Detect error shape + var errorProp = callResult.GetType().GetProperty("error"); + if (errorProp != null) + { + return Results.Json(new { jsonrpc = JsonRpcConstants.Version, id = idValue, error = errorProp.GetValue(callResult) }); + } + return Results.Json(new { jsonrpc = JsonRpcConstants.Version, id = idValue, result = callResult }); + } + catch (ArgumentException ex) + { + // Unknown MCP server name + log.LogWarning(ex, "No mock tools available for server '{McpServerName}'", mcpServerName); + return Results.Json(new + { + jsonrpc = JsonRpcConstants.Version, + id = idValue, + error = new + { + code = JsonRpcConstants.ErrorCodes.InvalidParams, + message = $"No mock tools available for server '{mcpServerName}'" + } + }); } - return Results.Json(new { jsonrpc = "2.0", id = idValue, result = callResult }); } - return Results.Json(new { jsonrpc = "2.0", id = idValue, error = new { code = -32601, message = $"Method ({method}) not found" } }); + // Handle MCP ping requests (used by clients to verify connection health) + if (string.Equals(method, "ping", StringComparison.OrdinalIgnoreCase)) + { + return Results.Json(new { jsonrpc = JsonRpcConstants.Version, id = idValue, result = new { } }); + } + // Handle prompts/list requests (return empty list - no mock prompts) + if (string.Equals(method, "prompts/list", StringComparison.OrdinalIgnoreCase)) + { + return Results.Json(new { jsonrpc = JsonRpcConstants.Version, id = idValue, result = new { prompts = Array.Empty() } }); + } + // Handle resources/list requests (return empty list - no mock resources) + if (string.Equals(method, "resources/list", StringComparison.OrdinalIgnoreCase)) + { + return Results.Json(new { jsonrpc = JsonRpcConstants.Version, id = idValue, result = new { resources = Array.Empty() } }); + } + + // Handle MCP notifications (e.g., notifications/initialized, notifications/cancelled) + // Per MCP Streamable HTTP spec: return 202 Accepted with no body for notifications + if (method?.StartsWith("notifications/", StringComparison.OrdinalIgnoreCase) == true) + { + return Results.StatusCode(JsonRpcConstants.HttpStatusCodes.Accepted); + } + + return Results.Json(new + { + jsonrpc = JsonRpcConstants.Version, + id = idValue, + error = new + { + code = JsonRpcConstants.ErrorCodes.MethodNotFound, + message = $"Method ({method}) not found" + } + }); } catch (Exception ex) { log.LogError(ex, "Mock JSON-RPC failure"); - return Results.Json(new { jsonrpc = "2.0", id = (object?)null, error = new { code = -32603, message = ex.Message } }); + return Results.Json(new + { + jsonrpc = JsonRpcConstants.Version, + id = idValue, + error = new + { + code = JsonRpcConstants.ErrorCodes.InternalError, + message = ex.Message + } + }); } }); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Microsoft.Agents.A365.DevTools.Cli.Tests.csproj b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Microsoft.Agents.A365.DevTools.Cli.Tests.csproj index 6c0eb4d..00da739 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Microsoft.Agents.A365.DevTools.Cli.Tests.csproj +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Microsoft.Agents.A365.DevTools.Cli.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockToolingServer/JsonRpcConstantsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockToolingServer/JsonRpcConstantsTests.cs new file mode 100644 index 0000000..1a8634a --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockToolingServer/JsonRpcConstantsTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.MockToolingServer.Constants; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.MockToolingServer; + +/// +/// Unit tests for JSON-RPC 2.0 constants used in MockToolingServer. +/// Ensures error codes and HTTP status codes match the JSON-RPC 2.0 specification. +/// +public class JsonRpcConstantsTests +{ + [Fact] + public void Version_ShouldBeJsonRpc20() + { + JsonRpcConstants.Version.Should().Be("2.0"); + } + + [Theory] + [InlineData(JsonRpcConstants.ErrorCodes.InvalidRequest, -32600)] + [InlineData(JsonRpcConstants.ErrorCodes.MethodNotFound, -32601)] + [InlineData(JsonRpcConstants.ErrorCodes.InvalidParams, -32602)] + [InlineData(JsonRpcConstants.ErrorCodes.InternalError, -32603)] + public void ErrorCodes_ShouldMatchJsonRpcSpecification(int actual, int expected) + { + actual.Should().Be(expected); + } + + [Fact] + public void HttpStatusCodes_Accepted_ShouldBe202() + { + JsonRpcConstants.HttpStatusCodes.Accepted.Should().Be(202); + } + + [Fact] + public void ErrorCodes_MethodNotFound_ShouldBeUsedForUnknownMethods() + { + // This test documents the intended use of MethodNotFound (-32601) + // It should be returned when the requested JSON-RPC method does not exist + JsonRpcConstants.ErrorCodes.MethodNotFound.Should().Be(-32601); + } + + [Fact] + public void ErrorCodes_InvalidParams_ShouldBeUsedForBadParameters() + { + // This test documents the intended use of InvalidParams (-32602) + // It should be returned when method parameters are invalid (e.g., unknown MCP server name) + JsonRpcConstants.ErrorCodes.InvalidParams.Should().Be(-32602); + } + + [Fact] + public void ErrorCodes_InternalError_ShouldBeUsedForUnexpectedFailures() + { + // This test documents the intended use of InternalError (-32603) + // It should be returned when an unexpected exception occurs during request processing + JsonRpcConstants.ErrorCodes.InternalError.Should().Be(-32603); + } +}