diff --git a/BuildWithAspire.slnx b/BuildWithAspire.slnx index 7fd00f5..41b5c2a 100644 --- a/BuildWithAspire.slnx +++ b/BuildWithAspire.slnx @@ -5,12 +5,14 @@ + + diff --git a/MCP_INTEGRATION.md b/MCP_INTEGRATION.md new file mode 100644 index 0000000..7aed2f4 --- /dev/null +++ b/MCP_INTEGRATION.md @@ -0,0 +1,287 @@ +# Model Context Protocol (MCP) Integration in Aspire + +## Overview + +This project demonstrates the integration of Model Context Protocol (MCP) with .NET Aspire, providing a standardized way for AI models to discover and use tools dynamically. MCP enables interoperable AI model hosting and serving by providing a consistent interface for tool discovery and execution. + +## What is MCP? + +Model Context Protocol (MCP) is a standard that: +- Enables AI models to discover available tools dynamically +- Provides a standardized interface for calling tools +- Allows for interoperable AI model hosting and serving +- Supports tool composition and chaining + +## Benefits of Using MCP in Aspire + +### 1. **Dynamic Tool Discovery** +- AI models can discover available tools at runtime +- No need to hardcode tool endpoints or interfaces +- Tools can be added or removed without changing AI model code + +### 2. **Service Orchestration** +- Aspire manages MCP server lifecycle and discovery +- Built-in health checks and monitoring +- Automatic service resolution and load balancing + +### 3. **Scalability** +- MCP servers can be scaled independently +- Multiple tool instances can be deployed +- Built-in telemetry and observability + +### 4. **Developer Experience** +- Standardized tool development patterns +- Easy testing and debugging +- Consistent API documentation + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ AI Service │────│ MCP Server │────│ Tool Suite │ +│ │ │ │ │ │ +│ - Chat Service │ │ - Tool Registry │ │ - Weather Tool │ +│ - AI Models │ │ - Request/Resp │ │ - Time Tool │ +│ - MCP Client │ │ - Validation │ │ - Custom Tools │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + └───────────────────────┼───────────────────────┘ + │ + ┌─────────────────┐ + │ Aspire AppHost │ + │ │ + │ - Service Disc. │ + │ - Health Checks │ + │ - Telemetry │ + └─────────────────┘ +``` + +## Implementation Details + +### MCP Server (`BuildWithAspire.McpServer`) + +The MCP Server hosts available tools and provides the following endpoints: + +#### Available Tools +```http +GET /mcp/tools +``` +Returns a list of all available tools with their descriptions and schemas. + +#### Call Tool +```http +POST /mcp/tools/call +Content-Type: application/json + +{ + "name": "get_weather", + "arguments": { + "location": "New York", + "days": 3 + } +} +``` + +### Available Tools + +#### 1. Weather Tool (`get_weather`) +Provides weather forecasts for specified locations. + +**Parameters:** +- `location` (string, required): The location to get weather for +- `days` (integer, optional): Number of days to forecast (1-7, default: 5) + +**Example Usage:** +```json +{ + "name": "get_weather", + "arguments": { + "location": "San Francisco", + "days": 3 + } +} +``` + +#### 2. Time Tool (`get_time`) +Provides current date and time information with timezone support. + +**Parameters:** +- `timezone` (string, optional): Timezone name (default: "UTC") +- `format` (string, optional): Format type - "iso", "local", "unix", or "detailed" (default: "iso") + +**Example Usage:** +```json +{ + "name": "get_time", + "arguments": { + "timezone": "America/New_York", + "format": "detailed" + } +} +``` + +### AI Service Integration + +The API Service (`BuildWithAspire.ApiService`) includes: + +1. **MCP Client Service**: Communicates with the MCP Server +2. **Smart Tool Detection**: Automatically detects when users ask for weather or time information +3. **Context Enrichment**: Adds tool results to AI conversation context + +#### Intelligent Tool Usage + +The AI service analyzes user messages and automatically calls appropriate MCP tools: + +- **Weather queries**: "What's the weather in Paris?" → calls `get_weather` tool +- **Time queries**: "What time is it?" → calls `get_time` tool +- **Regular chat**: Processes normally without tool calls + +### Aspire Integration + +In `BuildWithAspire.AppHost`, the MCP Server is registered as: + +```csharp +// Add MCP Server service +var mcpServer = builder.AddProject("mcpserver") + .WithExternalHttpEndpoints(); + +// Reference MCP Server from API Service +var apiService = builder.AddProject("apiservice") + .WithExternalHttpEndpoints() + .WithAIModel(aiService) + .WithReference(chatDb) + .WithReference(mcpServer) // Enables service discovery + .WaitFor(chatDb) + .WaitFor(mcpServer); +``` + +## Testing the MCP Implementation + +### 1. Demo Endpoint +Visit `/mcp/demo` to see all available tools and example calls: + +```http +GET https://localhost:5001/mcp/demo +``` + +### 2. Chat Integration +Create a conversation and ask for weather or time: + +```http +POST https://localhost:5001/conversations/{id}/messages +Content-Type: application/json + +{ + "message": "What's the weather like in London for the next 3 days?" +} +``` + +### 3. Direct Tool Calls +Call MCP tools directly through the MCP server: + +```http +POST https://localhost:5124/mcp/tools/call +Content-Type: application/json + +{ + "name": "get_weather", + "arguments": { + "location": "Tokyo", + "days": 5 + } +} +``` + +## Extending the MCP Implementation + +### Adding New Tools + +1. **Create Tool Class**: Implement the tool interface in `Tools/` directory +2. **Register Tool**: Add to `McpServer` constructor +3. **Update Documentation**: Add tool description and examples + +Example tool structure: +```csharp +public class CustomTool +{ + public string Name => "tool_name"; + public string Description => "Tool description"; + + public object GetParametersSchema() => new { /* schema */ }; + + public async Task ExecuteAsync(Dictionary parameters, CancellationToken cancellationToken = default) + { + // Tool implementation + } +} +``` + +### AI Model Integration + +To integrate with different AI providers: + +1. **Tool Calling**: Use AI model's native tool calling capabilities +2. **Schema Conversion**: Convert MCP schemas to AI-specific formats +3. **Response Processing**: Parse tool results and integrate into conversations + +## Best Practices + +### 1. **Tool Design** +- Keep tools focused and single-purpose +- Provide comprehensive parameter validation +- Include detailed error handling +- Use appropriate timeout values + +### 2. **Error Handling** +- Graceful degradation when tools are unavailable +- Meaningful error messages for users +- Proper logging and telemetry + +### 3. **Performance** +- Cache tool metadata when possible +- Use async patterns throughout +- Implement proper timeout and cancellation support + +### 4. **Security** +- Validate all tool parameters +- Implement rate limiting if needed +- Sanitize tool outputs before returning to AI + +## Monitoring and Observability + +The MCP implementation includes: + +- **Health Checks**: Built-in Aspire health monitoring +- **Telemetry**: OpenTelemetry integration for tracing +- **Logging**: Structured logging throughout the pipeline +- **Metrics**: Tool call success rates and performance metrics + +## Migration from Hardcoded Tools + +The original weather endpoint (`/weatherforecast`) remains available, demonstrating both approaches: + +- **Legacy**: Direct API endpoints with hardcoded functionality +- **MCP**: Dynamic tool discovery and execution + +This allows for gradual migration and comparison of approaches. + +## Future Enhancements + +Potential extensions to the MCP implementation: + +1. **Tool Chaining**: Allow tools to call other tools +2. **Dynamic Loading**: Load tools from external assemblies +3. **Version Management**: Support multiple versions of tools +4. **Tool Marketplace**: Discover and install community tools +5. **Advanced Schemas**: Support complex parameter types and validation + +## Conclusion + +The MCP integration demonstrates how modern AI applications can benefit from standardized tool protocols. By combining MCP with .NET Aspire, we achieve: + +- **Discoverability**: AI models can find and use tools dynamically +- **Interoperability**: Standard interfaces enable tool reuse +- **Scalability**: Aspire provides enterprise-grade hosting +- **Maintainability**: Clear separation of concerns and testable components + +This implementation serves as a foundation for building sophisticated AI applications with rich tool ecosystems. \ No newline at end of file diff --git a/src/BuildWithAspire.ApiService/Program.cs b/src/BuildWithAspire.ApiService/Program.cs index dac1201..a2e1229 100644 --- a/src/BuildWithAspire.ApiService/Program.cs +++ b/src/BuildWithAspire.ApiService/Program.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.AI; using Scalar.AspNetCore; +using System.Text.Json; using ChatRole = Microsoft.Extensions.AI.ChatRole; var builder = WebApplication.CreateBuilder(args); @@ -35,6 +36,13 @@ builder.Services.AddTransient(); +// Add MCP Client Service to communicate with MCP Server +builder.Services.AddHttpClient(client => +{ + // This will be configured by Aspire service discovery to point to the MCP server + client.BaseAddress = new Uri("http://mcpserver"); +}); + var app = builder.Build(); app.MapDefaultEndpoints(); @@ -199,7 +207,7 @@ static async Task GetWeatherSummary(IChatClient client, int temp) .WithName("CreateConversation") .WithOpenApi(); -app.MapPost("/conversations/{id}/messages", async (Guid id, [FromBody] SendMessageRequest request, ChatService chatService, ChatDbContext db) => +app.MapPost("/conversations/{id}/messages", async (Guid id, [FromBody] SendMessageRequest request, ChatService chatService, McpClientService mcpClient, ChatDbContext db) => { try { @@ -235,27 +243,138 @@ static async Task GetWeatherSummary(IChatClient client, int temp) // Save user message first await db.SaveChangesAsync().ConfigureAwait(false); - // Prepare history for AI - get all messages for this conversation including the new one - var messages = await db.Messages - .Where(m => m.ConversationId == id) - .OrderBy(m => m.CreatedAt) - .Select(m => new ChatMessageRequest - { - Role = m.Role, - Content = m.Content - }) - .ToListAsync().ConfigureAwait(false); - - // Get AI response with timeout handling + // Check if the user is asking for weather or time information + var message = request.Message.ToLowerInvariant(); string aiResponse; - try + + if (message.Contains("weather") || message.Contains("forecast")) + { + // Try to call MCP weather tool + try + { + // Extract location from user message (simple approach) + var location = "New York"; // Default location + if (message.Contains(" in ")) + { + var locationPart = message.Substring(message.IndexOf(" in ") + 4); + var words = locationPart.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (words.Length > 0) + { + location = string.Join(" ", words.Take(2)); // Take up to 2 words for location + } + } + + var weatherResult = await mcpClient.CallToolAsync("get_weather", new Dictionary + { + ["location"] = location, + ["days"] = 3 + }).ConfigureAwait(false); + + if (weatherResult != null) + { + var weatherJson = System.Text.Json.JsonSerializer.Serialize(weatherResult, new JsonSerializerOptions { WriteIndented = true }); + + // Prepare history for AI - get all messages for this conversation including the new one + var messages = await db.Messages + .Where(m => m.ConversationId == id) + .OrderBy(m => m.CreatedAt) + .Select(m => new ChatMessageRequest + { + Role = m.Role, + Content = m.Content + }) + .ToListAsync().ConfigureAwait(false); + + // Add the weather data to the context + messages.Add(new ChatMessageRequest + { + Role = "system", + Content = $"Weather data from MCP tool: {weatherJson}. Use this data to provide a helpful weather summary to the user." + }); + + aiResponse = await chatService.ProcessMessagesWithHistory(messages).ConfigureAwait(false); + } + else + { + aiResponse = "I'm sorry, I couldn't get the weather information right now. Please try again later."; + } + } + catch (Exception ex) + { + app.Logger.LogWarning(ex, "Failed to get weather from MCP tool"); + aiResponse = "I'm sorry, I encountered an error while trying to get weather information."; + } + } + else if (message.Contains("time") || message.Contains("date")) { - aiResponse = await chatService.ProcessMessagesWithHistory(messages).ConfigureAwait(false); + // Try to call MCP time tool + try + { + var timeResult = await mcpClient.CallToolAsync("get_time", new Dictionary + { + ["timezone"] = "UTC", + ["format"] = "detailed" + }).ConfigureAwait(false); + + if (timeResult != null) + { + var timeJson = System.Text.Json.JsonSerializer.Serialize(timeResult, new JsonSerializerOptions { WriteIndented = true }); + + // Prepare history for AI - get all messages for this conversation including the new one + var messages = await db.Messages + .Where(m => m.ConversationId == id) + .OrderBy(m => m.CreatedAt) + .Select(m => new ChatMessageRequest + { + Role = m.Role, + Content = m.Content + }) + .ToListAsync().ConfigureAwait(false); + + // Add the time data to the context + messages.Add(new ChatMessageRequest + { + Role = "system", + Content = $"Current time data from MCP tool: {timeJson}. Use this data to provide helpful time/date information to the user." + }); + + aiResponse = await chatService.ProcessMessagesWithHistory(messages).ConfigureAwait(false); + } + else + { + aiResponse = "I'm sorry, I couldn't get the current time information right now."; + } + } + catch (Exception ex) + { + app.Logger.LogWarning(ex, "Failed to get time from MCP tool"); + aiResponse = "I'm sorry, I encountered an error while trying to get time information."; + } } - catch (Exception ex) + else { - app.Logger.LogError(ex, "AI service error for conversation {ConversationId}", id); - return Results.Problem("Failed to get AI response", statusCode: 500); + // Regular chat processing + // Prepare history for AI - get all messages for this conversation including the new one + var messages = await db.Messages + .Where(m => m.ConversationId == id) + .OrderBy(m => m.CreatedAt) + .Select(m => new ChatMessageRequest + { + Role = m.Role, + Content = m.Content + }) + .ToListAsync().ConfigureAwait(false); + + // Get AI response with timeout handling + try + { + aiResponse = await chatService.ProcessMessagesWithHistory(messages).ConfigureAwait(false); + } + catch (Exception ex) + { + app.Logger.LogError(ex, "AI service error for conversation {ConversationId}", id); + return Results.Problem("Failed to get AI response", statusCode: 500); + } } // Add assistant message @@ -304,6 +423,41 @@ static async Task GetWeatherSummary(IChatClient client, int temp) .WithName("GetChat") .WithOpenApi(); +// Add MCP tools demonstration endpoint +app.MapGet("/mcp/demo", async (McpClientService mcpClient) => +{ + try + { + var tools = await mcpClient.GetAvailableToolsAsync().ConfigureAwait(false); + + var weatherDemo = await mcpClient.CallToolAsync("get_weather", new Dictionary + { + ["location"] = "San Francisco", + ["days"] = 2 + }).ConfigureAwait(false); + + var timeDemo = await mcpClient.CallToolAsync("get_time", new Dictionary + { + ["timezone"] = "UTC", + ["format"] = "detailed" + }).ConfigureAwait(false); + + return Results.Ok(new + { + message = "MCP Tools Demo", + available_tools = tools?.Tools?.Select(t => new { t.Name, t.Description }), + weather_demo = weatherDemo, + time_demo = timeDemo + }); + } + catch (Exception ex) + { + return Results.Problem($"MCP Demo failed: {ex.Message}"); + } +}) +.WithName("McpDemo") +.WithOpenApi(); + app.Run(); public record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) diff --git a/src/BuildWithAspire.ApiService/Services/McpClientService.cs b/src/BuildWithAspire.ApiService/Services/McpClientService.cs new file mode 100644 index 0000000..cf8443b --- /dev/null +++ b/src/BuildWithAspire.ApiService/Services/McpClientService.cs @@ -0,0 +1,106 @@ +using System.Text.Json; + +namespace BuildWithAspire.ApiService.Services; + +/// +/// Service to interact with MCP tools. +/// +public class McpClientService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public McpClientService(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + /// + /// Get available MCP tools. + /// + public async Task GetAvailableToolsAsync(CancellationToken cancellationToken = default) + { + try + { + _logger.LogDebug("Fetching available MCP tools"); + var response = await _httpClient.GetAsync("/mcp/tools", cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var result = JsonSerializer.Deserialize(content, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + _logger.LogInformation("Retrieved {ToolCount} MCP tools", result?.Tools?.Count ?? 0); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get available MCP tools"); + return null; + } + } + + /// + /// Call an MCP tool. + /// + public async Task CallToolAsync(string toolName, Dictionary arguments, CancellationToken cancellationToken = default) + { + try + { + _logger.LogDebug("Calling MCP tool: {ToolName} with {ArgumentCount} arguments", toolName, arguments.Count); + + var request = new McpToolCallRequest + { + Name = toolName, + Arguments = arguments + }; + + var json = JsonSerializer.Serialize(request); + var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync("/mcp/tools/call", content, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var result = JsonSerializer.Deserialize(responseContent); + + _logger.LogInformation("Successfully called MCP tool: {ToolName}", toolName); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to call MCP tool: {ToolName}", toolName); + throw new InvalidOperationException($"Failed to call MCP tool '{toolName}': {ex.Message}", ex); + } + } +} + +/// +/// Response structure for MCP tools listing. +/// +public class McpToolsResponse +{ + public List Tools { get; set; } = new(); +} + +/// +/// Information about an MCP tool. +/// +public class McpToolInfo +{ + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public object? InputSchema { get; set; } +} + +/// +/// Request structure for calling MCP tools. +/// +public class McpToolCallRequest +{ + public string Name { get; set; } = string.Empty; + public Dictionary Arguments { get; set; } = new(); +} \ No newline at end of file diff --git a/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj b/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj index 0ec973d..cfe27d4 100644 --- a/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj +++ b/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj @@ -24,6 +24,7 @@ + diff --git a/src/BuildWithAspire.AppHost/Program.cs b/src/BuildWithAspire.AppHost/Program.cs index db9d17b..507c0eb 100644 --- a/src/BuildWithAspire.AppHost/Program.cs +++ b/src/BuildWithAspire.AppHost/Program.cs @@ -32,16 +32,24 @@ // Add API service with AI model configuration var aiService = builder.AddAIModel(); +// Add MCP Server service +var mcpServer = builder.AddProject("mcpserver") + .WithExternalHttpEndpoints(); + var apiService = builder.AddProject("apiservice") .WithExternalHttpEndpoints() .WithAIModel(aiService) .WithReference(chatDb) - .WaitFor(chatDb); + .WithReference(mcpServer) + .WaitFor(chatDb) + .WaitFor(mcpServer); // Add Web service var _ = builder.AddProject("webfrontend") .WithExternalHttpEndpoints() .WithReference(apiService) - .WaitFor(apiService); + .WithReference(mcpServer) + .WaitFor(apiService) + .WaitFor(mcpServer); builder.Build().Run(); diff --git a/src/BuildWithAspire.McpServer/BuildWithAspire.McpServer.csproj b/src/BuildWithAspire.McpServer/BuildWithAspire.McpServer.csproj new file mode 100644 index 0000000..b65a777 --- /dev/null +++ b/src/BuildWithAspire.McpServer/BuildWithAspire.McpServer.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/BuildWithAspire.McpServer/McpServer.cs b/src/BuildWithAspire.McpServer/McpServer.cs new file mode 100644 index 0000000..0b10a95 --- /dev/null +++ b/src/BuildWithAspire.McpServer/McpServer.cs @@ -0,0 +1,59 @@ +using BuildWithAspire.McpServer.Tools; + +namespace BuildWithAspire.McpServer; + +/// +/// MCP tool call request structure. +/// +public class McpToolCallRequest +{ + public string Name { get; set; } = string.Empty; + public Dictionary Arguments { get; set; } = new(); +} + +/// +/// MCP Server that manages tool calls. +/// +public class McpServer +{ + private readonly Dictionary, CancellationToken, Task>> _tools; + private readonly Dictionary _toolMetadata; + + public McpServer(WeatherTool weatherTool, TimeTool timeTool) + { + _tools = new Dictionary, CancellationToken, Task>> + { + [weatherTool.Name] = weatherTool.ExecuteAsync, + [timeTool.Name] = timeTool.ExecuteAsync + }; + + _toolMetadata = new Dictionary + { + [weatherTool.Name] = (weatherTool.Description, weatherTool.GetParametersSchema()), + [timeTool.Name] = (timeTool.Description, timeTool.GetParametersSchema()) + }; + } + + public async Task CallToolAsync(McpToolCallRequest request) + { + if (!_tools.TryGetValue(request.Name, out var tool)) + { + throw new InvalidOperationException($"Tool '{request.Name}' not found"); + } + + return await tool(request.Arguments, CancellationToken.None).ConfigureAwait(false); + } + + public object GetAvailableTools() + { + return new + { + tools = _toolMetadata.Select(kvp => new + { + name = kvp.Key, + description = kvp.Value.Description, + inputSchema = kvp.Value.Schema + }).ToList() + }; + } +} \ No newline at end of file diff --git a/src/BuildWithAspire.McpServer/Program.cs b/src/BuildWithAspire.McpServer/Program.cs new file mode 100644 index 0000000..055209f --- /dev/null +++ b/src/BuildWithAspire.McpServer/Program.cs @@ -0,0 +1,52 @@ +using BuildWithAspire.McpServer; +using BuildWithAspire.McpServer.Tools; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +// Add services to the container. +builder.Services.AddOpenApi(); + +// Add MCP Server +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); + +app.MapGet("/", () => "MCP Server is running"); + +// Add MCP endpoints +app.MapPost("/mcp/tools/call", async (McpToolCallRequest request, McpServer mcpServer) => +{ + try + { + var result = await mcpServer.CallToolAsync(request).ConfigureAwait(false); + return Results.Ok(result); + } + catch (Exception ex) + { + return Results.Problem($"Tool call failed: {ex.Message}"); + } +}); + +app.MapGet("/mcp/tools", (McpServer mcpServer) => +{ + return Results.Ok(mcpServer.GetAvailableTools()); +}); + +app.Run(); + +// Make Program class accessible for testing +public partial class Program { } \ No newline at end of file diff --git a/src/BuildWithAspire.McpServer/Tools/TimeTool.cs b/src/BuildWithAspire.McpServer/Tools/TimeTool.cs new file mode 100644 index 0000000..996eb33 --- /dev/null +++ b/src/BuildWithAspire.McpServer/Tools/TimeTool.cs @@ -0,0 +1,110 @@ +namespace BuildWithAspire.McpServer.Tools; + +/// +/// Time tool for MCP that provides current date and time information. +/// +public class TimeTool +{ + public string Name => "get_time"; + public string Description => "Get current date and time information in various formats"; + + public object GetParametersSchema() + { + return new + { + type = "object", + properties = new + { + timezone = new + { + type = "string", + description = "Timezone name (e.g., 'UTC', 'America/New_York', 'Europe/London') - defaults to UTC", + @default = "UTC" + }, + format = new + { + type = "string", + description = "Date/time format - 'iso', 'local', 'unix', or 'detailed'", + @default = "iso" + } + }, + required = System.Array.Empty() // No required parameters + }; + } + + public async Task ExecuteAsync(Dictionary parameters, CancellationToken cancellationToken = default) + { + var timezone = parameters.GetValueOrDefault("timezone", "UTC")?.ToString() ?? "UTC"; + var format = parameters.GetValueOrDefault("format", "iso")?.ToString() ?? "iso"; + + // Handle JsonElement properly for timezone + if (parameters.GetValueOrDefault("timezone") is System.Text.Json.JsonElement timezoneElement) + { + if (timezoneElement.ValueKind == System.Text.Json.JsonValueKind.String) + { + timezone = timezoneElement.GetString() ?? "UTC"; + } + } + + // Handle JsonElement properly for format + if (parameters.GetValueOrDefault("format") is System.Text.Json.JsonElement formatElement) + { + if (formatElement.ValueKind == System.Text.Json.JsonValueKind.String) + { + format = formatElement.GetString() ?? "iso"; + } + } + + var utcNow = DateTime.UtcNow; + var localNow = DateTime.Now; + + // Try to get timezone specific time + DateTime targetTime = utcNow; + string timezoneDisplay = timezone; + + try + { + var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezone); + targetTime = TimeZoneInfo.ConvertTimeFromUtc(utcNow, timeZoneInfo); + timezoneDisplay = timeZoneInfo.DisplayName; + } + catch + { + // Fallback to UTC if timezone not found + timezone = "UTC"; + timezoneDisplay = "Coordinated Universal Time"; + } + + await Task.Delay(50, cancellationToken).ConfigureAwait(false); // Simulate some processing time + + var result = new + { + timezone = timezone, + timezone_display = timezoneDisplay, + format_requested = format, + current_time = format.ToLowerInvariant() switch + { + "iso" => (object)targetTime.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture), + "local" => (object)targetTime.ToString("F", System.Globalization.CultureInfo.InvariantCulture), + "unix" => (object)((DateTimeOffset)targetTime).ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture), + "detailed" => (object)new + { + iso = targetTime.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture), + local = targetTime.ToString("F", System.Globalization.CultureInfo.InvariantCulture), + unix_timestamp = ((DateTimeOffset)targetTime).ToUnixTimeSeconds(), + day_of_week = targetTime.DayOfWeek.ToString(), + day_of_year = targetTime.DayOfYear, + week_of_year = System.Globalization.CultureInfo.InvariantCulture.Calendar.GetWeekOfYear( + targetTime, + System.Globalization.CalendarWeekRule.FirstDay, + DayOfWeek.Sunday) + }, + _ => (object)targetTime.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture) + }, + utc_time = utcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture), + generated_at = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture) + }; + + return result; + } +} \ No newline at end of file diff --git a/src/BuildWithAspire.McpServer/Tools/WeatherTool.cs b/src/BuildWithAspire.McpServer/Tools/WeatherTool.cs new file mode 100644 index 0000000..ef9690b --- /dev/null +++ b/src/BuildWithAspire.McpServer/Tools/WeatherTool.cs @@ -0,0 +1,94 @@ +using System.ComponentModel; +using System.Text.Json; + +namespace BuildWithAspire.McpServer.Tools; + +/// +/// Weather tool for MCP that provides weather forecasts. +/// +public class WeatherTool +{ + public string Name => "get_weather"; + public string Description => "Get weather forecast for a specified location and number of days"; + + public object GetParametersSchema() + { + return new + { + type = "object", + properties = new + { + location = new + { + type = "string", + description = "The location to get weather for (city, country)" + }, + days = new + { + type = "integer", + description = "Number of days to forecast (1-7, default: 5)", + minimum = 1, + maximum = 7, + @default = 5 + } + }, + required = new[] { "location" } + }; + } + + public async Task ExecuteAsync(Dictionary parameters, CancellationToken cancellationToken = default) + { + var location = parameters.GetValueOrDefault("location", "Unknown Location")?.ToString() ?? "Unknown Location"; + + // Handle JsonElement properly + var daysObj = parameters.GetValueOrDefault("days", 5); + int days = 5; // default + + if (daysObj is System.Text.Json.JsonElement jsonElement) + { + if (jsonElement.ValueKind == System.Text.Json.JsonValueKind.Number) + { + days = jsonElement.GetInt32(); + } + } + else + { + days = Convert.ToInt32(daysObj, System.Globalization.CultureInfo.InvariantCulture); + } + + // Clamp days to valid range + days = Math.Max(1, Math.Min(7, days)); + + var forecasts = new List(); + var random = new Random(); + var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; + + for (int i = 0; i < days; i++) + { + var date = DateOnly.FromDateTime(DateTime.Now.AddDays(i + 1)); + var tempC = random.Next(-20, 55); + var tempF = 32 + (int)(tempC / 0.5556); + var summary = summaries[random.Next(summaries.Length)]; + + forecasts.Add(new + { + date = date.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture), + location = location, + temperatureC = tempC, + temperatureF = tempF, + summary = summary, + description = $"{summary} weather expected in {location} with temperature around {tempC}°C ({tempF}°F)" + }); + } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); // Simulate some processing time + + return new + { + location = location, + forecast_days = days, + forecasts = forecasts, + generated_at = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture) + }; + } +} \ No newline at end of file diff --git a/tests/BuildWithAspire.McpServer.UnitTests/BuildWithAspire.McpServer.UnitTests.csproj b/tests/BuildWithAspire.McpServer.UnitTests/BuildWithAspire.McpServer.UnitTests.csproj new file mode 100644 index 0000000..b1e24bb --- /dev/null +++ b/tests/BuildWithAspire.McpServer.UnitTests/BuildWithAspire.McpServer.UnitTests.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + \ No newline at end of file diff --git a/tests/BuildWithAspire.McpServer.UnitTests/McpServerTests.cs b/tests/BuildWithAspire.McpServer.UnitTests/McpServerTests.cs new file mode 100644 index 0000000..42137cf --- /dev/null +++ b/tests/BuildWithAspire.McpServer.UnitTests/McpServerTests.cs @@ -0,0 +1,319 @@ +using BuildWithAspire.McpServer.Tools; +using System.Text.Json; +using Xunit; + +namespace BuildWithAspire.McpServer.UnitTests; + +public class WeatherToolTests +{ + private readonly WeatherTool _weatherTool; + + public WeatherToolTests() + { + _weatherTool = new WeatherTool(); + } + + [Fact] + public void Name_ShouldReturnExpectedValue() + { + // Act + var name = _weatherTool.Name; + + // Assert + Assert.Equal("get_weather", name); + } + + [Fact] + public void Description_ShouldReturnExpectedValue() + { + // Act + var description = _weatherTool.Description; + + // Assert + Assert.Equal("Get weather forecast for a specified location and number of days", description); + } + + [Fact] + public void GetParametersSchema_ShouldReturnValidSchema() + { + // Act + var schema = _weatherTool.GetParametersSchema(); + + // Assert + Assert.NotNull(schema); + + var json = JsonSerializer.Serialize(schema); + Assert.Contains("location", json); + Assert.Contains("days", json); + Assert.Contains("required", json); + } + + [Fact] + public async Task ExecuteAsync_WithValidParameters_ShouldReturnWeatherData() + { + // Arrange + var parameters = new Dictionary + { + ["location"] = "New York", + ["days"] = 3 + }; + + // Act + var result = await _weatherTool.ExecuteAsync(parameters); + + // Assert + Assert.NotNull(result); + + var json = JsonSerializer.Serialize(result); + Assert.Contains("New York", json); + Assert.Contains("forecasts", json); + Assert.Contains("location", json); + } + + [Fact] + public async Task ExecuteAsync_WithJsonElements_ShouldHandleCorrectly() + { + // Arrange - Simulate JSON deserialized parameters + var daysElement = JsonSerializer.Deserialize("2"); + var parameters = new Dictionary + { + ["location"] = "Paris", + ["days"] = daysElement + }; + + // Act + var result = await _weatherTool.ExecuteAsync(parameters); + + // Assert + Assert.NotNull(result); + + var json = JsonSerializer.Serialize(result); + Assert.Contains("Paris", json); + Assert.Contains("forecast_days", json); + } + + [Fact] + public async Task ExecuteAsync_WithDefaultDays_ShouldUse5Days() + { + // Arrange + var parameters = new Dictionary + { + ["location"] = "London" + }; + + // Act + var result = await _weatherTool.ExecuteAsync(parameters); + + // Assert + Assert.NotNull(result); + + var resultObj = JsonSerializer.Deserialize(JsonSerializer.Serialize(result)); + var forecastDays = resultObj.GetProperty("forecast_days").GetInt32(); + Assert.Equal(5, forecastDays); + } + + [Fact] + public async Task ExecuteAsync_WithInvalidDaysRange_ShouldClampToValidRange() + { + // Arrange + var parameters = new Dictionary + { + ["location"] = "Tokyo", + ["days"] = 10 // Should be clamped to 7 + }; + + // Act + var result = await _weatherTool.ExecuteAsync(parameters); + + // Assert + Assert.NotNull(result); + + var resultObj = JsonSerializer.Deserialize(JsonSerializer.Serialize(result)); + var forecastDays = resultObj.GetProperty("forecast_days").GetInt32(); + Assert.Equal(7, forecastDays); // Should be clamped to max 7 + } +} + +public class TimeToolTests +{ + private readonly TimeTool _timeTool; + + public TimeToolTests() + { + _timeTool = new TimeTool(); + } + + [Fact] + public void Name_ShouldReturnExpectedValue() + { + // Act + var name = _timeTool.Name; + + // Assert + Assert.Equal("get_time", name); + } + + [Fact] + public void Description_ShouldReturnExpectedValue() + { + // Act + var description = _timeTool.Description; + + // Assert + Assert.Equal("Get current date and time information in various formats", description); + } + + [Fact] + public async Task ExecuteAsync_WithUTCTimezone_ShouldReturnUTCTime() + { + // Arrange + var parameters = new Dictionary + { + ["timezone"] = "UTC", + ["format"] = "iso" + }; + + // Act + var result = await _timeTool.ExecuteAsync(parameters); + + // Assert + Assert.NotNull(result); + + var json = JsonSerializer.Serialize(result); + Assert.Contains("UTC", json); + Assert.Contains("current_time", json); + } + + [Fact] + public async Task ExecuteAsync_WithDetailedFormat_ShouldReturnDetailedInfo() + { + // Arrange + var parameters = new Dictionary + { + ["timezone"] = "UTC", + ["format"] = "detailed" + }; + + // Act + var result = await _timeTool.ExecuteAsync(parameters); + + // Assert + Assert.NotNull(result); + + var json = JsonSerializer.Serialize(result); + Assert.Contains("day_of_week", json); + Assert.Contains("unix_timestamp", json); + Assert.Contains("week_of_year", json); + } + + [Fact] + public async Task ExecuteAsync_WithNoParameters_ShouldUseDefaults() + { + // Arrange + var parameters = new Dictionary(); + + // Act + var result = await _timeTool.ExecuteAsync(parameters); + + // Assert + Assert.NotNull(result); + + var resultObj = JsonSerializer.Deserialize(JsonSerializer.Serialize(result)); + var timezone = resultObj.GetProperty("timezone").GetString(); + Assert.Equal("UTC", timezone); + } +} + +public class McpServerTests +{ + private readonly McpServer _mcpServer; + private readonly WeatherTool _weatherTool; + private readonly TimeTool _timeTool; + + public McpServerTests() + { + _weatherTool = new WeatherTool(); + _timeTool = new TimeTool(); + _mcpServer = new McpServer(_weatherTool, _timeTool); + } + + [Fact] + public void GetAvailableTools_ShouldReturnAllTools() + { + // Act + var result = _mcpServer.GetAvailableTools(); + + // Assert + Assert.NotNull(result); + + var json = JsonSerializer.Serialize(result); + Assert.Contains("get_weather", json); + Assert.Contains("get_time", json); + Assert.Contains("tools", json); + } + + [Fact] + public async Task CallToolAsync_WithValidWeatherRequest_ShouldReturnWeatherData() + { + // Arrange + var request = new McpToolCallRequest + { + Name = "get_weather", + Arguments = new Dictionary + { + ["location"] = "Berlin", + ["days"] = 2 + } + }; + + // Act + var result = await _mcpServer.CallToolAsync(request); + + // Assert + Assert.NotNull(result); + + var json = JsonSerializer.Serialize(result); + Assert.Contains("Berlin", json); + } + + [Fact] + public async Task CallToolAsync_WithValidTimeRequest_ShouldReturnTimeData() + { + // Arrange + var request = new McpToolCallRequest + { + Name = "get_time", + Arguments = new Dictionary + { + ["timezone"] = "UTC", + ["format"] = "iso" + } + }; + + // Act + var result = await _mcpServer.CallToolAsync(request); + + // Assert + Assert.NotNull(result); + + var json = JsonSerializer.Serialize(result); + Assert.Contains("UTC", json); + } + + [Fact] + public async Task CallToolAsync_WithInvalidToolName_ShouldThrowException() + { + // Arrange + var request = new McpToolCallRequest + { + Name = "invalid_tool", + Arguments = new Dictionary() + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _mcpServer.CallToolAsync(request)); + + Assert.Contains("Tool 'invalid_tool' not found", exception.Message); + } +} \ No newline at end of file