diff --git a/Core/Resgrid.Config/McpConfig.cs b/Core/Resgrid.Config/McpConfig.cs index 09b4a649..b09efd08 100644 --- a/Core/Resgrid.Config/McpConfig.cs +++ b/Core/Resgrid.Config/McpConfig.cs @@ -1,4 +1,4 @@ -namespace Resgrid.Config +namespace Resgrid.Config { /// /// Configuration settings for the Model Context Protocol (MCP) server @@ -17,9 +17,29 @@ public static class McpConfig public static string ServerVersion = "1.0.0"; /// - /// The transport mechanism for MCP (e.g., "stdio") + /// The transport mechanism for MCP (e.g., "stdio", "http") /// - public static string Transport = "stdio"; + public static string Transport = "http"; + + /// + /// Enable CORS for HTTP transport (allows cross-origin requests) + /// + public static bool EnableCors = true; + + /// + /// Allowed CORS origins (comma-separated list). Empty or "*" allows all origins. + /// + public static string CorsAllowedOrigins = "*"; + + /// + /// Enable HTTP transport endpoint + /// + public static bool EnableHttpTransport = true; + + /// + /// Enable stdio transport (for backwards compatibility) + /// + public static bool EnableStdioTransport = false; } } diff --git a/Tests/Resgrid.Tests/Resgrid.Tests.csproj b/Tests/Resgrid.Tests/Resgrid.Tests.csproj index a48f4e38..a7939dcb 100644 --- a/Tests/Resgrid.Tests/Resgrid.Tests.csproj +++ b/Tests/Resgrid.Tests/Resgrid.Tests.csproj @@ -50,6 +50,7 @@ + @@ -57,4 +58,4 @@ Always - \ No newline at end of file + diff --git a/Tests/Resgrid.Tests/Web/Mcp/SensitiveDataRedactorTests.cs b/Tests/Resgrid.Tests/Web/Mcp/SensitiveDataRedactorTests.cs new file mode 100644 index 00000000..9b015c3b --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Mcp/SensitiveDataRedactorTests.cs @@ -0,0 +1,208 @@ +using NUnit.Framework; +using Resgrid.Web.Mcp.Infrastructure; + +namespace Resgrid.Tests.Web.Mcp +{ + /// + /// Unit tests for SensitiveDataRedactor to verify sensitive field redaction + /// + [TestFixture] + public sealed class SensitiveDataRedactorTests + { + private const string RedactedValue = "***REDACTED***"; + + [Test] + public void RedactSensitiveFields_ShouldRedactPassword() + { + // Arrange + var jsonWithPassword = @"{""username"":""john.doe"",""password"":""secret123""}"; + + // Act + var redacted = SensitiveDataRedactor.RedactSensitiveFields(jsonWithPassword); + + // Assert + Assert.That(redacted, Does.Contain(RedactedValue), "Redacted output should contain redaction marker"); + Assert.That(redacted, Does.Not.Contain("secret123"), "Redacted output should not contain original password"); + Assert.That(redacted, Does.Not.Contain("john.doe"), "Redacted output should not contain username value"); + } + + [Test] + public void RedactSensitiveFields_ShouldRedactTokenAndApiKey() + { + // Arrange + var jsonWithToken = @"{""token"":""Bearer abc123"",""apikey"":""xyz789"",""data"":""safe data""}"; + + // Act + var redacted = SensitiveDataRedactor.RedactSensitiveFields(jsonWithToken); + + // Assert + Assert.That(redacted, Does.Contain(RedactedValue), "Redacted output should contain redaction marker"); + Assert.That(redacted, Does.Not.Contain("Bearer abc123"), "Redacted output should not contain original token"); + Assert.That(redacted, Does.Not.Contain("xyz789"), "Redacted output should not contain original API key"); + Assert.That(redacted, Does.Contain("safe data"), "Redacted output should preserve non-sensitive data"); + } + + [Test] + public void RedactSensitiveFields_ShouldRedactNestedSensitiveFields() + { + // Arrange + var nestedJson = @"{""user"":{""username"":""jane"",""password"":""pass456""},""sessionToken"":""token123""}"; + + // Act + var redacted = SensitiveDataRedactor.RedactSensitiveFields(nestedJson); + + // Assert + Assert.That(redacted, Does.Contain(RedactedValue), "Redacted output should contain redaction marker"); + Assert.That(redacted, Does.Not.Contain("jane"), "Redacted output should not contain nested username"); + Assert.That(redacted, Does.Not.Contain("pass456"), "Redacted output should not contain nested password"); + Assert.That(redacted, Does.Not.Contain("token123"), "Redacted output should not contain session token"); + } + + [Test] + public void RedactSensitiveFields_ShouldRedactJsonRpcRequest() + { + // Arrange + var jsonRpcRequest = @"{""jsonrpc"":""2.0"",""method"":""authenticate"",""params"":{""username"":""admin"",""password"":""admin123""},""id"":1}"; + + // Act + var redacted = SensitiveDataRedactor.RedactSensitiveFields(jsonRpcRequest); + + // Assert + Assert.That(redacted, Does.Contain(RedactedValue), "Redacted output should contain redaction marker"); + Assert.That(redacted, Does.Not.Contain("admin123"), "Redacted output should not contain password from params"); + Assert.That(redacted, Does.Not.Contain("admin"), "Redacted output should not contain username from params"); + Assert.That(redacted, Does.Contain("authenticate"), "Redacted output should preserve method name"); + Assert.That(redacted, Does.Contain("2.0"), "Redacted output should preserve jsonrpc version"); + } + + [Test] + public void RedactSensitiveFields_ShouldHandleInvalidJson() + { + // Arrange + var invalidJson = "not valid json {"; + + // Act + var redacted = SensitiveDataRedactor.RedactSensitiveFields(invalidJson); + + // Assert + Assert.That(redacted, Is.Not.Null, "Should return non-null result for invalid JSON"); + Assert.That(redacted, Is.Not.Empty, "Should return non-empty result for invalid JSON"); + Assert.That(redacted, Does.Not.Contain("not valid json"), "Should not contain original invalid JSON content"); + } + + [Test] + public void RedactSensitiveFields_ShouldHandleEmptyString() + { + // Arrange + var emptyJson = string.Empty; + + // Act + var redacted = SensitiveDataRedactor.RedactSensitiveFields(emptyJson); + + // Assert + Assert.That(redacted, Is.Empty, "Should return empty string for empty input"); + } + + [Test] + public void RedactSensitiveFields_ShouldHandleNullString() + { + // Arrange + string nullJson = null; + + // Act + var redacted = SensitiveDataRedactor.RedactSensitiveFields(nullJson); + + // Assert + Assert.That(redacted, Is.Empty, "Should return empty string for null input"); + } + + [Test] + public void RedactSensitiveFields_ShouldPreserveNonSensitiveFields() + { + // Arrange + var jsonWithMixedFields = @"{""id"":42,""name"":""John"",""email"":""john@example.com"",""status"":""active""}"; + + // Act + var redacted = SensitiveDataRedactor.RedactSensitiveFields(jsonWithMixedFields); + + // Assert + Assert.That(redacted, Does.Contain(RedactedValue), "Should redact email field"); + Assert.That(redacted, Does.Not.Contain("john@example.com"), "Should not contain original email"); + Assert.That(redacted, Does.Contain("42"), "Should preserve id field"); + Assert.That(redacted, Does.Contain("John"), "Should preserve name field"); + Assert.That(redacted, Does.Contain("active"), "Should preserve status field"); + } + + [Test] + public void RedactSensitiveFields_ShouldRedactArraysWithSensitiveData() + { + // Arrange + var jsonWithArray = @"{""users"":[{""username"":""user1"",""password"":""pass1""},{""username"":""user2"",""password"":""pass2""}]}"; + + // Act + var redacted = SensitiveDataRedactor.RedactSensitiveFields(jsonWithArray); + + // Assert + Assert.That(redacted, Does.Contain(RedactedValue), "Should redact sensitive fields in array"); + Assert.That(redacted, Does.Not.Contain("user1"), "Should not contain first username"); + Assert.That(redacted, Does.Not.Contain("user2"), "Should not contain second username"); + Assert.That(redacted, Does.Not.Contain("pass1"), "Should not contain first password"); + Assert.That(redacted, Does.Not.Contain("pass2"), "Should not contain second password"); + } + + [Test] + public void RedactSensitiveFields_ShouldRedactCommonSensitiveFieldNames() + { + // Arrange + var jsonWithVariousSensitiveFields = @"{ + ""password"":""secret"", + ""token"":""token123"", + ""ssn"":""123-45-6789"", + ""apikey"":""key123"", + ""api_key"":""key456"", + ""secret"":""secret789"", + ""authorization"":""Bearer xyz"", + ""auth"":""auth123"", + ""credentials"":""creds"", + ""credit_card"":""4111111111111111"", + ""creditcard"":""4111111111111111"", + ""cvv"":""123"", + ""pin"":""1234"" + }"; + + // Act + var redacted = SensitiveDataRedactor.RedactSensitiveFields(jsonWithVariousSensitiveFields); + + // Assert + Assert.That(redacted, Does.Contain(RedactedValue), "Should contain redaction marker"); + Assert.That(redacted, Does.Not.Contain("secret"), "Should not contain 'secret' value"); + Assert.That(redacted, Does.Not.Contain("token123"), "Should not contain token value"); + Assert.That(redacted, Does.Not.Contain("123-45-6789"), "Should not contain SSN"); + Assert.That(redacted, Does.Not.Contain("key123"), "Should not contain apikey value"); + Assert.That(redacted, Does.Not.Contain("key456"), "Should not contain api_key value"); + Assert.That(redacted, Does.Not.Contain("secret789"), "Should not contain secret value"); + Assert.That(redacted, Does.Not.Contain("Bearer xyz"), "Should not contain authorization value"); + Assert.That(redacted, Does.Not.Contain("auth123"), "Should not contain auth value"); + Assert.That(redacted, Does.Not.Contain("creds"), "Should not contain credentials value"); + Assert.That(redacted, Does.Not.Contain("4111111111111111"), "Should not contain credit card number"); + Assert.That(redacted, Does.Not.Contain("1234"), "Should not contain PIN"); + } + + [Test] + public void RedactSensitiveFields_ShouldHandleCaseInsensitiveFieldNames() + { + // Arrange + var jsonWithMixedCase = @"{""Password"":""secret"",""TOKEN"":""token123"",""ApiKey"":""key456""}"; + + // Act + var redacted = SensitiveDataRedactor.RedactSensitiveFields(jsonWithMixedCase); + + // Assert + Assert.That(redacted, Does.Contain(RedactedValue), "Should redact case-insensitive field names"); + Assert.That(redacted, Does.Not.Contain("secret"), "Should not contain password value"); + Assert.That(redacted, Does.Not.Contain("token123"), "Should not contain token value"); + Assert.That(redacted, Does.Not.Contain("key456"), "Should not contain API key value"); + } + } +} + diff --git a/Web/Resgrid.Web.Mcp/Controllers/HealthController.cs b/Web/Resgrid.Web.Mcp/Controllers/HealthController.cs index 355f6ff7..9bc5f461 100644 --- a/Web/Resgrid.Web.Mcp/Controllers/HealthController.cs +++ b/Web/Resgrid.Web.Mcp/Controllers/HealthController.cs @@ -36,6 +36,7 @@ public HealthController( /// /// HealthResult object with the server health status [HttpGet("current")] + [HttpGet("getcurrent")] public async Task GetCurrent() { var result = new HealthResult diff --git a/Web/Resgrid.Web.Mcp/Controllers/McpController.cs b/Web/Resgrid.Web.Mcp/Controllers/McpController.cs new file mode 100644 index 00000000..a8094e88 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Controllers/McpController.cs @@ -0,0 +1,144 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Resgrid.Web.Mcp.ModelContextProtocol; +using Resgrid.Web.Mcp.Infrastructure; + +namespace Resgrid.Web.Mcp.Controllers +{ + /// + /// HTTP controller that exposes the MCP JSON-RPC interface over HTTP + /// + [ApiController] + [Route("mcp")] + public sealed class McpController : ControllerBase + { + private readonly IMcpRequestHandler _mcpHandler; + private readonly ILogger _logger; + + // Maximum request body size: 1MB + private const long MaxRequestBodySize = 1_048_576; + + public McpController(IMcpRequestHandler mcpHandler, ILogger logger) + { + _mcpHandler = mcpHandler; + _logger = logger; + } + + /// + /// Handles MCP JSON-RPC requests over HTTP POST + /// + /// Cancellation token + /// JSON-RPC response + [HttpPost] + [Consumes("application/json")] + [Produces("application/json")] + [RequestSizeLimit(MaxRequestBodySize)] + public async Task HandleRequest(CancellationToken cancellationToken) + { + try + { + // Validate request body size to prevent unbounded memory allocation + var contentLength = Request.ContentLength; + if (contentLength.HasValue && contentLength.Value > MaxRequestBodySize) + { + _logger.LogWarning("Request body size {Size} bytes exceeds maximum allowed size {MaxSize} bytes", + contentLength.Value, MaxRequestBodySize); + return StatusCode(StatusCodes.Status413PayloadTooLarge, new + { + jsonrpc = "2.0", + id = (object)null, + error = new + { + code = -32600, + message = $"Request body too large. Maximum size is {MaxRequestBodySize} bytes." + } + }); + } + + // Read the raw request body + using var reader = new StreamReader(Request.Body); + var requestBody = await reader.ReadToEndAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(requestBody)) + { + _logger.LogWarning("Received empty request body"); + return BadRequest(new + { + jsonrpc = "2.0", + id = (object)null, + error = new + { + code = -32600, + message = "Invalid Request: Empty request body" + } + }); + } + + var redactedRequest = SensitiveDataRedactor.RedactSensitiveFields(requestBody); + _logger.LogDebug("Received MCP request: {Request}", redactedRequest); + + // Process the request through the MCP handler + var response = await _mcpHandler.HandleRequestAsync(requestBody, cancellationToken); + + var redactedResponse = SensitiveDataRedactor.RedactSensitiveFields(response); + _logger.LogDebug("Sending MCP response: {Response}", redactedResponse); + + // Return the JSON-RPC response + return Content(response, "application/json"); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to parse JSON-RPC request"); + return BadRequest(new + { + jsonrpc = "2.0", + id = (object)null, + error = new + { + code = -32700, + message = "Parse error", + data = ex.Message + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error handling MCP request"); + return StatusCode(StatusCodes.Status500InternalServerError, new + { + jsonrpc = "2.0", + id = (object)null, + error = new + { + code = -32603, + message = "Internal error" + } + }); + } + } + + /// + /// GET endpoint for basic connectivity check + /// + [HttpGet] + public IActionResult Get() + { + return Ok(new + { + service = "Resgrid MCP Server", + version = "1.0.0", + protocol = "Model Context Protocol", + transport = "HTTP", + endpoint = "/mcp", + method = "POST" + }); + } + } +} + diff --git a/Web/Resgrid.Web.Mcp/Dockerfile b/Web/Resgrid.Web.Mcp/Dockerfile index df1f98d8..c201b0c1 100644 --- a/Web/Resgrid.Web.Mcp/Dockerfile +++ b/Web/Resgrid.Web.Mcp/Dockerfile @@ -5,8 +5,7 @@ ARG BUILD_VERSION=3.5.0 FROM mcr.microsoft.com/dotnet/aspnet:9.0.3-noble-amd64 AS base ARG BUILD_VERSION WORKDIR /app -EXPOSE 80 -EXPOSE 5050 +EXPOSE 8080 FROM mcr.microsoft.com/dotnet/sdk:9.0.202-noble-amd64 AS build ARG BUILD_VERSION diff --git a/Web/Resgrid.Web.Mcp/Examples/McpHttpClient.cs b/Web/Resgrid.Web.Mcp/Examples/McpHttpClient.cs new file mode 100644 index 00000000..43fda343 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Examples/McpHttpClient.cs @@ -0,0 +1,178 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Web.Mcp.Examples +{ + /// + /// Example HTTP client for connecting to the MCP server + /// + public sealed class McpHttpClient : IDisposable + { + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + private int _requestId; + + public McpHttpClient(string baseUrl) + { + _baseUrl = baseUrl; + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + } + + /// + /// Initialize the MCP connection + /// + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + var request = new + { + jsonrpc = "2.0", + id = Interlocked.Increment(ref _requestId), + method = "initialize", + @params = new + { + protocolVersion = "2024-11-05", + capabilities = new { }, + clientInfo = new + { + name = "example-client", + version = "1.0.0" + } + } + }; + + return await SendRequestAsync(request, cancellationToken); + } + + /// + /// List all available tools + /// + public async Task ListToolsAsync(CancellationToken cancellationToken = default) + { + var request = new + { + jsonrpc = "2.0", + id = Interlocked.Increment(ref _requestId), + method = "tools/list", + @params = new { } + }; + + return await SendRequestAsync(request, cancellationToken); + } + + /// + /// Call a specific tool + /// + public async Task CallToolAsync(string toolName, object arguments, CancellationToken cancellationToken = default) + { + var request = new + { + jsonrpc = "2.0", + id = Interlocked.Increment(ref _requestId), + method = "tools/call", + @params = new + { + name = toolName, + arguments + } + }; + + return await SendRequestAsync(request, cancellationToken); + } + + /// + /// Ping the server + /// + public async Task PingAsync(CancellationToken cancellationToken = default) + { + var request = new + { + jsonrpc = "2.0", + id = Interlocked.Increment(ref _requestId), + method = "ping", + @params = new { } + }; + + return await SendRequestAsync(request, cancellationToken); + } + + private async Task SendRequestAsync(object request, CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(request); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(_baseUrl, content, cancellationToken); + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); + return JsonDocument.Parse(responseJson); + } + + public void Dispose() + { + _httpClient?.Dispose(); + } + } + + /// + /// Example usage of the MCP HTTP client + /// + public static class McpHttpClientExample + { + public static async Task RunExampleAsync() + { + using var client = new McpHttpClient("http://localhost:8080/mcp"); + + try + { + // Initialize connection + Console.WriteLine("Initializing MCP connection..."); + var initResponse = await client.InitializeAsync(); + Console.WriteLine($"Initialize response: {initResponse.RootElement}"); + Console.WriteLine(); + + // List available tools + Console.WriteLine("Listing available tools..."); + var toolsResponse = await client.ListToolsAsync(); + var tools = toolsResponse.RootElement.GetProperty("result").GetProperty("tools"); + Console.WriteLine($"Available tools ({tools.GetArrayLength()}):"); + foreach (var tool in tools.EnumerateArray()) + { + var name = tool.GetProperty("name").GetString(); + var description = tool.GetProperty("description").GetString(); + Console.WriteLine($" - {name}: {description}"); + } + Console.WriteLine(); + + // Ping server + Console.WriteLine("Pinging server..."); + var pingResponse = await client.PingAsync(); + Console.WriteLine($"Ping response: {pingResponse.RootElement}"); + Console.WriteLine(); + + // Example: Call authenticate tool + Console.WriteLine("Calling authenticate tool..."); + var authArgs = new + { + username = "your-username", + password = "your-password" + }; + var authResponse = await client.CallToolAsync("authenticate", authArgs); + Console.WriteLine($"Auth response: {authResponse.RootElement}"); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + } + } + } +} + + diff --git a/Web/Resgrid.Web.Mcp/Infrastructure/SensitiveDataRedactor.cs b/Web/Resgrid.Web.Mcp/Infrastructure/SensitiveDataRedactor.cs new file mode 100644 index 00000000..ca06987e --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Infrastructure/SensitiveDataRedactor.cs @@ -0,0 +1,148 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Resgrid.Web.Mcp.Infrastructure +{ + /// + /// Provides functionality to redact sensitive fields from JSON payloads before logging + /// + public static class SensitiveDataRedactor + { + private static readonly string[] SensitiveFields = new[] + { + "password", + "username", + "token", + "ssn", + "email", + "apikey", + "api_key", + "secret", + "authorization", + "auth", + "credentials", + "credit_card", + "creditcard", + "cvv", + "pin" + }; + + private const string RedactedValue = "***REDACTED***"; + + /// + /// Redacts sensitive fields from a JSON string + /// + /// The JSON string to redact + /// A redacted version of the JSON string, or metadata if parsing fails + public static string RedactSensitiveFields(string jsonString) + { + if (string.IsNullOrWhiteSpace(jsonString)) + { + return string.Empty; + } + + try + { + var jsonNode = JsonNode.Parse(jsonString); + if (jsonNode == null) + { + return GetMetadataOnly(jsonString); + } + + RedactNode(jsonNode); + return jsonNode.ToJsonString(new JsonSerializerOptions { WriteIndented = false }); + } + catch (JsonException) + { + // If parsing fails, return only metadata + return GetMetadataOnly(jsonString); + } + } + + /// + /// Extracts only metadata from a JSON string without sensitive data + /// + /// The JSON string + /// A string containing only metadata + private static string GetMetadataOnly(string jsonString) + { + try + { + using var doc = JsonDocument.Parse(jsonString); + var root = doc.RootElement; + + var metadata = new + { + method = root.TryGetProperty("method", out var method) ? method.GetString() : null, + id = root.TryGetProperty("id", out var id) ? id.ToString() : null, + jsonrpc = root.TryGetProperty("jsonrpc", out var jsonrpc) ? jsonrpc.GetString() : null, + hasParams = root.TryGetProperty("params", out _), + hasResult = root.TryGetProperty("result", out _), + hasError = root.TryGetProperty("error", out _) + }; + + return JsonSerializer.Serialize(metadata); + } + catch + { + return $"[Length: {jsonString.Length} bytes]"; + } + } + + /// + /// Recursively redacts sensitive fields in a JSON node + /// + /// The JSON node to redact + private static void RedactNode(JsonNode node) + { + if (node is JsonObject jsonObject) + { + // Create a list of properties to avoid modifying while iterating + var propertiesToProcess = new System.Collections.Generic.List>(); + foreach (var property in jsonObject) + { + propertiesToProcess.Add(new System.Collections.Generic.KeyValuePair(property.Key, property.Value)); + } + + foreach (var property in propertiesToProcess) + { + var propertyName = property.Key.ToLowerInvariant(); + + // Check if this property name matches a sensitive field + var isSensitive = false; + foreach (var sensitiveField in SensitiveFields) + { + if (propertyName.Contains(sensitiveField)) + { + isSensitive = true; + break; + } + } + + if (isSensitive) + { + jsonObject[property.Key] = RedactedValue; + } + else if (property.Value != null) + { + RedactNode(property.Value); + } + } + } + else if (node is JsonArray jsonArray) + { + for (int i = 0; i < jsonArray.Count; i++) + { + var item = jsonArray[i]; + if (item != null) + { + RedactNode(item); + } + } + } + } + } +} + + + diff --git a/Web/Resgrid.Web.Mcp/McpServerHost.cs b/Web/Resgrid.Web.Mcp/McpServerHost.cs index 6ac76414..04406d62 100644 --- a/Web/Resgrid.Web.Mcp/McpServerHost.cs +++ b/Web/Resgrid.Web.Mcp/McpServerHost.cs @@ -1,9 +1,9 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; +using Resgrid.Web.Mcp.ModelContextProtocol; using Resgrid.Config; using Sentry; diff --git a/Web/Resgrid.Web.Mcp/McpToolRegistry.cs b/Web/Resgrid.Web.Mcp/McpToolRegistry.cs index 21c65d2e..9372c34d 100644 --- a/Web/Resgrid.Web.Mcp/McpToolRegistry.cs +++ b/Web/Resgrid.Web.Mcp/McpToolRegistry.cs @@ -1,6 +1,6 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; +using Resgrid.Web.Mcp.ModelContextProtocol; using Resgrid.Web.Mcp.Tools; namespace Resgrid.Web.Mcp diff --git a/Web/Resgrid.Web.Mcp/ModelContextProtocol/IMcpRequestHandler.cs b/Web/Resgrid.Web.Mcp/ModelContextProtocol/IMcpRequestHandler.cs new file mode 100644 index 00000000..2929b0d7 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/ModelContextProtocol/IMcpRequestHandler.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Web.Mcp.ModelContextProtocol +{ + /// + /// Interface for handling MCP JSON-RPC requests + /// + public interface IMcpRequestHandler + { + /// + /// Handles a JSON-RPC request and returns a JSON-RPC response + /// + /// The JSON-RPC request as a string + /// Cancellation token + /// The JSON-RPC response as a string + Task HandleRequestAsync(string requestJson, CancellationToken cancellationToken); + } +} + diff --git a/Web/Resgrid.Web.Mcp/ModelContextProtocol/McpServer.cs b/Web/Resgrid.Web.Mcp/ModelContextProtocol/McpServer.cs index 3a882574..c3430767 100644 --- a/Web/Resgrid.Web.Mcp/ModelContextProtocol/McpServer.cs +++ b/Web/Resgrid.Web.Mcp/ModelContextProtocol/McpServer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Text.Json; @@ -7,12 +7,12 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; -namespace ModelContextProtocol.Server +namespace Resgrid.Web.Mcp.ModelContextProtocol { /// /// Simple MCP Server implementation based on the Model Context Protocol specification /// - public sealed class McpServer + public sealed class McpServer : IMcpRequestHandler { private readonly string _serverName; private readonly string _serverVersion; @@ -38,6 +38,35 @@ public void AddTool(string name, string description, Dictionary }; } + /// + /// Handles a JSON-RPC request string and returns a JSON-RPC response string + /// + public async Task HandleRequestAsync(string requestJson, CancellationToken cancellationToken) + { + try + { + var request = JsonSerializer.Deserialize(requestJson); + var response = await HandleRequestAsync(request, cancellationToken); + return JsonSerializer.Serialize(response); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error processing request"); + var errorResponse = new JsonRpcResponse + { + Jsonrpc = "2.0", + Id = null, + Error = new JsonRpcError + { + Code = -32603, + Message = "Internal error", + Data = null + } + }; + return JsonSerializer.Serialize(errorResponse); + } + } + public async Task RunAsync(CancellationToken cancellationToken) { _logger?.LogInformation("MCP Server starting stdio transport"); @@ -72,10 +101,7 @@ public async Task RunAsync(CancellationToken cancellationToken) try { - var request = JsonSerializer.Deserialize(line); - var response = await HandleRequestAsync(request, cancellationToken); - - var responseJson = JsonSerializer.Serialize(response); + var responseJson = await HandleRequestAsync(line, cancellationToken); await Console.Out.WriteLineAsync(responseJson); await Console.Out.FlushAsync(); } diff --git a/Web/Resgrid.Web.Mcp/Program.cs b/Web/Resgrid.Web.Mcp/Program.cs index 585a897e..6d5dce5e 100644 --- a/Web/Resgrid.Web.Mcp/Program.cs +++ b/Web/Resgrid.Web.Mcp/Program.cs @@ -63,6 +63,11 @@ public static IHostBuilder CreateHostBuilder(string[] args) => if (samplingContext.CustomSamplingContext.TryGetValue("__HttpPath", out var httpPath)) { var pathValue = httpPath?.ToString(); + if (string.Equals(pathValue, "/health/current", StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + if (string.Equals(pathValue, "/health/getcurrent", StringComparison.OrdinalIgnoreCase)) { return 0; @@ -77,8 +82,8 @@ public static IHostBuilder CreateHostBuilder(string[] args) => webBuilder.UseKestrel(serverOptions => { - // Configure Kestrel to listen on a specific port for health checks - serverOptions.ListenAnyIP(5050); + // Configure Kestrel to listen on port 8080 for MCP HTTP requests and health checks + serverOptions.ListenAnyIP(8080); }); webBuilder.UseStartup(); diff --git a/Web/Resgrid.Web.Mcp/Startup.cs b/Web/Resgrid.Web.Mcp/Startup.cs index dc0e0c8f..5e5525d2 100644 --- a/Web/Resgrid.Web.Mcp/Startup.cs +++ b/Web/Resgrid.Web.Mcp/Startup.cs @@ -1,10 +1,12 @@ -using System; +using System; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Resgrid.Config; using Resgrid.Web.Mcp.Infrastructure; +using Resgrid.Web.Mcp.ModelContextProtocol; using Resgrid.Web.Mcp.Tools; namespace Resgrid.Web.Mcp @@ -26,13 +28,65 @@ public void ConfigureServices(IServiceCollection services) Framework.Logging.Initialize(ExternalErrorConfig.ExternalErrorServiceUrlForMcp); } - // Register MCP server - services.AddHostedService(); + // Register MCP Server as a singleton for HTTP access + services.AddSingleton(sp => + { + var logger = sp.GetRequiredService>(); + var serverName = McpConfig.ServerName; + var serverVersion = McpConfig.ServerVersion; + var mcpServer = new McpServer(serverName, serverVersion, logger); + + // Register tools with the server + var toolRegistry = sp.GetRequiredService(); + toolRegistry.RegisterTools(mcpServer); + + return mcpServer; + }); + + // Register IMcpRequestHandler interface + services.AddSingleton(sp => + sp.GetRequiredService()); + + // Register MCP server hosted service (for stdio transport) + // Only enable if configured + if (McpConfig.EnableStdioTransport) + { + services.AddHostedService(); + } - // Add MVC controllers for health check endpoint + // Add MVC controllers for MCP and health check endpoints services.AddControllers() .AddNewtonsoftJson(); + // Add CORS support for browser-based MCP clients + if (McpConfig.EnableCors) + { + services.AddCors(options => + { + options.AddPolicy("McpCorsPolicy", builder => + { + if (string.IsNullOrWhiteSpace(McpConfig.CorsAllowedOrigins) || McpConfig.CorsAllowedOrigins == "*") + { + // Allow all origins (development/testing) + builder + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + } + else + { + // Specific origins (production) + var origins = McpConfig.CorsAllowedOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries); + builder + .WithOrigins(origins) + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials(); + } + }); + }); + } + // Register infrastructure services services.AddMemoryCache(); services.AddSingleton(); @@ -81,6 +135,12 @@ public void ConfigureServices(IServiceCollection services) public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { + // Enable CORS if configured + if (McpConfig.EnableCors) + { + app.UseCors("McpCorsPolicy"); + } + app.UseRouting(); app.UseEndpoints(endpoints => diff --git a/Web/Resgrid.Web.Mcp/Tools/AuthenticationToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/AuthenticationToolProvider.cs index 8793239e..acb16940 100644 --- a/Web/Resgrid.Web.Mcp/Tools/AuthenticationToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/AuthenticationToolProvider.cs @@ -1,11 +1,11 @@ -using System; +using System; using System.Collections.Generic; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; +using Resgrid.Web.Mcp.ModelContextProtocol; using Newtonsoft.Json; namespace Resgrid.Web.Mcp.Tools diff --git a/Web/Resgrid.Web.Mcp/Tools/CalendarToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/CalendarToolProvider.cs index ef428083..a42bfa54 100644 --- a/Web/Resgrid.Web.Mcp/Tools/CalendarToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/CalendarToolProvider.cs @@ -1,8 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; +using Resgrid.Web.Mcp.ModelContextProtocol; using Newtonsoft.Json; namespace Resgrid.Web.Mcp.Tools diff --git a/Web/Resgrid.Web.Mcp/Tools/CallsToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/CallsToolProvider.cs index cc849ea1..bdad08cd 100644 --- a/Web/Resgrid.Web.Mcp/Tools/CallsToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/CallsToolProvider.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; +using Resgrid.Web.Mcp.ModelContextProtocol; using Newtonsoft.Json; using Sentry; diff --git a/Web/Resgrid.Web.Mcp/Tools/DispatchToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/DispatchToolProvider.cs index b42a9dba..8e0bee63 100644 --- a/Web/Resgrid.Web.Mcp/Tools/DispatchToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/DispatchToolProvider.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; +using Resgrid.Web.Mcp.ModelContextProtocol; using Newtonsoft.Json; namespace Resgrid.Web.Mcp.Tools diff --git a/Web/Resgrid.Web.Mcp/Tools/InventoryToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/InventoryToolProvider.cs index 8709ef76..2dd40760 100644 --- a/Web/Resgrid.Web.Mcp/Tools/InventoryToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/InventoryToolProvider.cs @@ -1,8 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; +using Resgrid.Web.Mcp.ModelContextProtocol; using Newtonsoft.Json; namespace Resgrid.Web.Mcp.Tools diff --git a/Web/Resgrid.Web.Mcp/Tools/MessagesToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/MessagesToolProvider.cs index 1547e5c7..f9b93c36 100644 --- a/Web/Resgrid.Web.Mcp/Tools/MessagesToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/MessagesToolProvider.cs @@ -1,8 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; +using Resgrid.Web.Mcp.ModelContextProtocol; using Newtonsoft.Json; namespace Resgrid.Web.Mcp.Tools diff --git a/Web/Resgrid.Web.Mcp/Tools/PersonnelToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/PersonnelToolProvider.cs index 7df3db64..7ec92ff5 100644 --- a/Web/Resgrid.Web.Mcp/Tools/PersonnelToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/PersonnelToolProvider.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; +using Resgrid.Web.Mcp.ModelContextProtocol; using Newtonsoft.Json; namespace Resgrid.Web.Mcp.Tools diff --git a/Web/Resgrid.Web.Mcp/Tools/ReportsToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/ReportsToolProvider.cs index 38e66431..e3abe56b 100644 --- a/Web/Resgrid.Web.Mcp/Tools/ReportsToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/ReportsToolProvider.cs @@ -1,8 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; +using Resgrid.Web.Mcp.ModelContextProtocol; using Newtonsoft.Json; namespace Resgrid.Web.Mcp.Tools diff --git a/Web/Resgrid.Web.Mcp/Tools/ShiftsToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/ShiftsToolProvider.cs index 61a3b17f..225de876 100644 --- a/Web/Resgrid.Web.Mcp/Tools/ShiftsToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/ShiftsToolProvider.cs @@ -1,8 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; +using Resgrid.Web.Mcp.ModelContextProtocol; using Newtonsoft.Json; namespace Resgrid.Web.Mcp.Tools diff --git a/Web/Resgrid.Web.Mcp/Tools/UnitsToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/UnitsToolProvider.cs index c0f4c3f9..0cc57e88 100644 --- a/Web/Resgrid.Web.Mcp/Tools/UnitsToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/UnitsToolProvider.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; +using Resgrid.Web.Mcp.ModelContextProtocol; using Newtonsoft.Json; namespace Resgrid.Web.Mcp.Tools