diff --git a/src/Deckster.McpServer.Tests/Deckster.McpServer.Tests.csproj b/src/Deckster.McpServer.Tests/Deckster.McpServer.Tests.csproj new file mode 100644 index 00000000..b39fe2b6 --- /dev/null +++ b/src/Deckster.McpServer.Tests/Deckster.McpServer.Tests.csproj @@ -0,0 +1,31 @@ + + + + net9.0 + enable + true + enable + latest + false + true + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Deckster.McpServer.Tests/Tools/DecksterGameToolsTests.cs b/src/Deckster.McpServer.Tests/Tools/DecksterGameToolsTests.cs new file mode 100644 index 00000000..d2fa098d --- /dev/null +++ b/src/Deckster.McpServer.Tests/Tools/DecksterGameToolsTests.cs @@ -0,0 +1,438 @@ +using Deckster.McpServer.Tools; +using FluentAssertions; +using System.Reflection; +using ModelContextProtocol.Server; + +namespace Deckster.McpServer.Tests.Tools; + +[TestFixture] +public class DecksterGameToolsTests +{ + [Test] + public async Task ListAvailableGames_ReturnsListOfGames() + { + // Arrange + var tools = new DecksterGameTools(); + + // Act + var result = await tools.ListAvailableGames(); + + // Assert + result.Should().Contain("CrazyEights"); + } + + [Test] + public void ListAvailableGames_HasMcpToolAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.ListAvailableGames)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should be marked as an MCP tool"); + } + + [Test] + public void ListAvailableGames_HasDescriptionAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.ListAvailableGames)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should have a description"); + attribute?.Description.Should().NotBeNullOrEmpty(); + } + + [Test] + public void DecksterGameTools_HasMcpToolTypeAttribute() + { + // Arrange & Act + var attribute = typeof(DecksterGameTools).GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the class should be marked as an MCP tool type"); + } + + [Test] + public void CreateGameAsync_HasMcpToolAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.CreateGameAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should be marked as an MCP tool"); + } + + [Test] + public void CreateGameAsync_HasDescriptionAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.CreateGameAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should have a description"); + attribute?.Description.Should().Contain("game"); + } + + [Test] + public void AddBotAsync_HasMcpToolAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.AddBotAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should be marked as an MCP tool"); + } + + [Test] + public void AddBotAsync_HasDescriptionAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.AddBotAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should have a description"); + attribute?.Description.Should().Contain("bot"); + } + + [Test] + public void StartGameAsync_HasMcpToolAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.StartGameAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should be marked as an MCP tool"); + } + + [Test] + public void StartGameAsync_HasDescriptionAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.StartGameAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should have a description"); + attribute?.Description.Should().Contain("Start"); + } + + [Test] + public void GetPreviousGamesAsync_HasMcpToolAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetPreviousGamesAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should be marked as an MCP tool"); + } + + [Test] + public void GetPreviousGamesAsync_HasDescriptionAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetPreviousGamesAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should have a description"); + attribute?.Description.Should().Contain("previous"); + } + + [Test] + public void GetActiveGamesAsync_HasMcpToolAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetActiveGamesAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should be marked as an MCP tool"); + } + + [Test] + public void GetActiveGamesAsync_HasDescriptionAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetActiveGamesAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should have a description"); + attribute?.Description.Should().Contain("active"); + } + + [Test] + public void GetGameStateAsync_HasMcpToolAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetGameStateAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should be marked as an MCP tool"); + } + + [Test] + public void GetGameStateAsync_HasDescriptionAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetGameStateAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should have a description"); + attribute?.Description.Should().Contain("state"); + } + + [Test] + public void DeleteGameAsync_HasMcpToolAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.DeleteGameAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should be marked as an MCP tool"); + } + + [Test] + public void DeleteGameAsync_HasDescriptionAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.DeleteGameAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should have a description"); + attribute?.Description.Should().ContainEquivalentOf("delete"); + } + + [Test] + public void GetGameOverviewAsync_HasMcpToolAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetGameOverviewAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should be marked as an MCP tool"); + } + + [Test] + public void GetGameOverviewAsync_HasDescriptionAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetGameOverviewAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should have a description"); + attribute?.Description.Should().ContainAny("overview", "Overview"); + } + + [Test] + public void GetHistoricalGameAsync_HasMcpToolAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetHistoricalGameAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should be marked as an MCP tool"); + } + + [Test] + public void GetHistoricalGameAsync_HasDescriptionAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetHistoricalGameAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should have a description"); + attribute?.Description.Should().ContainAny("historical", "Historical"); + } + + [Test] + public void GetHistoricalGameVersionAsync_HasMcpToolAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetHistoricalGameVersionAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should be marked as an MCP tool"); + } + + [Test] + public void GetHistoricalGameVersionAsync_HasDescriptionAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetHistoricalGameVersionAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should have a description"); + attribute?.Description.Should().ContainAny("version", "Version"); + } + + [Test] + public void GetGameMetadataAsync_HasMcpToolAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetGameMetadataAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should be marked as an MCP tool"); + } + + [Test] + public void GetGameMetadataAsync_HasDescriptionAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetGameMetadataAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should have a description"); + attribute?.Description.Should().ContainAny("metadata", "Metadata"); + } + + [Test] + public void GetGameDescriptionAsync_HasMcpToolAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetGameDescriptionAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should be marked as an MCP tool"); + } + + [Test] + public void GetGameDescriptionAsync_HasDescriptionAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetGameDescriptionAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should have a description"); + attribute?.Description.Should().ContainAny("description", "Description"); + } + + [Test] + public void GetCurrentUserAsync_HasMcpToolAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetCurrentUserAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should be marked as an MCP tool"); + } + + [Test] + public void GetCurrentUserAsync_HasDescriptionAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetCurrentUserAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should have a description"); + attribute?.Description.Should().ContainAny("user", "User"); + } + + [Test] + public void GetMessageMetadataAsync_HasMcpToolAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetMessageMetadataAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should be marked as an MCP tool"); + } + + [Test] + public void GetMessageMetadataAsync_HasDescriptionAttribute() + { + // Arrange + var method = typeof(DecksterGameTools).GetMethod(nameof(DecksterGameTools.GetMessageMetadataAsync)); + + // Act + var attribute = method?.GetCustomAttribute(); + + // Assert + attribute.Should().NotBeNull("the method should have a description"); + attribute?.Description.Should().ContainAny("message", "Message"); + } +} diff --git a/src/Deckster.McpServer.Tests/Wrappers/DecksterClientWrapperTests.cs b/src/Deckster.McpServer.Tests/Wrappers/DecksterClientWrapperTests.cs new file mode 100644 index 00000000..15204984 --- /dev/null +++ b/src/Deckster.McpServer.Tests/Wrappers/DecksterClientWrapperTests.cs @@ -0,0 +1,78 @@ +using Deckster.Client; +using Deckster.McpServer.Wrappers; +using FluentAssertions; + +namespace Deckster.McpServer.Tests.Wrappers; + +[TestFixture] +public class DecksterClientWrapperTests +{ + [Test] + public void Constructor_NullClient_ThrowsArgumentNullException() + { + // Arrange & Act + var action = () => new DecksterClientWrapper(null!, new HttpClient(), "http://localhost:13992"); + + // Assert + action.Should().Throw() + .WithParameterName("client"); + } + + [Test] + public void Constructor_ValidClient_CreatesInstance() + { + // Arrange + var client = new DecksterClient("http://localhost:13992", "test-token"); + var httpClient = new HttpClient(); + + // Act + var wrapper = new DecksterClientWrapper(client, httpClient, "http://localhost:13992"); + + // Assert + wrapper.Should().NotBeNull(); + } + + [Test] + public void CreateGameAsync_MethodExists() + { + // Arrange + var method = typeof(DecksterClientWrapper).GetMethod(nameof(DecksterClientWrapper.CreateGameAsync)); + + // Assert + method.Should().NotBeNull("CreateGameAsync method should exist"); + method?.ReturnType.Should().Be(typeof(Task), "should return Task"); + } + + [Test] + public void AddBotAsync_MethodExists() + { + // Arrange + var method = typeof(DecksterClientWrapper).GetMethod(nameof(DecksterClientWrapper.AddBotAsync)); + + // Assert + method.Should().NotBeNull("AddBotAsync method should exist"); + method?.ReturnType.Should().Be(typeof(Task), "should return Task"); + } + + [Test] + public void StartGameAsync_MethodExists() + { + // Arrange + var method = typeof(DecksterClientWrapper).GetMethod(nameof(DecksterClientWrapper.StartGameAsync)); + + // Assert + method.Should().NotBeNull("StartGameAsync method should exist"); + method?.ReturnType.Should().Be(typeof(Task), "should return Task"); + } + + [Test] + public void GetPreviousGamesAsync_MethodExists() + { + // Arrange + var method = typeof(DecksterClientWrapper).GetMethod(nameof(DecksterClientWrapper.GetPreviousGamesAsync)); + + // Assert + method.Should().NotBeNull("GetPreviousGamesAsync method should exist"); + method?.ReturnType.Should().Be(typeof(Task), "should return Task"); + } +} diff --git a/src/Deckster.McpServer/Configuration/DecksterConfiguration.cs b/src/Deckster.McpServer/Configuration/DecksterConfiguration.cs new file mode 100644 index 00000000..c6e79f9b --- /dev/null +++ b/src/Deckster.McpServer/Configuration/DecksterConfiguration.cs @@ -0,0 +1,18 @@ +namespace Deckster.McpServer.Configuration; + +public class DecksterConfiguration +{ + public string ServerUrl { get; set; } = "http://localhost:13992"; + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + + public static DecksterConfiguration FromEnvironment() + { + return new DecksterConfiguration + { + ServerUrl = Environment.GetEnvironmentVariable("DECKSTER_SERVER_URL") ?? "http://localhost:13992", + Username = Environment.GetEnvironmentVariable("DECKSTER_USERNAME") ?? string.Empty, + Password = Environment.GetEnvironmentVariable("DECKSTER_PASSWORD") ?? string.Empty + }; + } +} diff --git a/src/Deckster.McpServer/Deckster.McpServer.csproj b/src/Deckster.McpServer/Deckster.McpServer.csproj new file mode 100644 index 00000000..1b95c75e --- /dev/null +++ b/src/Deckster.McpServer/Deckster.McpServer.csproj @@ -0,0 +1,23 @@ + + + + Exe + net9.0 + enable + true + enable + latest + + + + + + + + + + + + + + diff --git a/src/Deckster.McpServer/Program.cs b/src/Deckster.McpServer/Program.cs new file mode 100644 index 00000000..c9b8296b --- /dev/null +++ b/src/Deckster.McpServer/Program.cs @@ -0,0 +1,42 @@ +using Deckster.McpServer.Configuration; +using Deckster.McpServer.Tools; +using Deckster.McpServer.Wrappers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; + +namespace Deckster.McpServer; + +public class Program +{ + public static async Task Main(string[] args) + { + var configuration = DecksterConfiguration.FromEnvironment(); + + var builder = Host.CreateApplicationBuilder(args); + + // Configure logging + builder.Logging.ClearProviders(); + builder.Logging.AddConsole(); + builder.Logging.SetMinimumLevel(LogLevel.Information); + + // Register services + builder.Services.AddSingleton(configuration); + + // Create and register DecksterClientWrapper asynchronously + var clientWrapper = await DecksterClientWrapper.CreateAsync(configuration); + builder.Services.AddSingleton(clientWrapper); + + // Register DecksterGameTools + builder.Services.AddSingleton(); + + // Configure MCP server + builder.Services.AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(); + + var host = builder.Build(); + await host.RunAsync(); + } +} diff --git a/src/Deckster.McpServer/README.md b/src/Deckster.McpServer/README.md new file mode 100644 index 00000000..535b4a5b --- /dev/null +++ b/src/Deckster.McpServer/README.md @@ -0,0 +1,293 @@ +# Deckster MCP Server + +A Model Context Protocol (MCP) server that enables AI coding assistants like Claude Code, GitHub Copilot, and Cursor to interact with the Deckster card game platform. + +## Overview + +The Deckster MCP Server provides programmatic access to the Deckster card game platform through the Model Context Protocol. This allows AI assistants to: + +- 🎴 Browse available card games +- 🎮 Create and manage game sessions +- 🤖 Add AI players to games +- ▶️ Control game flow +- 🎯 Automate testing of multiplayer games + +## Features + +### Game Management +- List all available card games (CrazyEights, Bullshit, Idiot, Uno, Yaniv, Gabong, TexasHoldEm) +- Create new game sessions with custom names +- Add AI bot players with configurable difficulty +- Start, pause, and manage game state + +### Supported Games +- **CrazyEights** - Fully implemented with complete API support +- **Other games** - Listed with basic support (implementation in progress) + +## Requirements + +- **.NET 9.0 SDK** or higher +- **Deckster Server** running (default: http://localhost:13992) +- Valid Deckster account credentials +- MCP-compatible client (Claude Code, GitHub Copilot, Cursor) + +## Installation + +### For Claude Code + +Install using the Claude Code CLI: + +```bash +# Coming soon - package publication pending +claude mcp add deckster dotnet run --project /path/to/Deckster.McpServer.csproj +``` + +### Manual Installation + +1. **Build the MCP Server** +```bash +cd src/Deckster.McpServer +dotnet build +``` + +2. **Configure your MCP client** + +Add to your MCP configuration: + +```json +{ + "mcpServers": { + "deckster": { + "command": "dotnet", + "args": [ + "run", + "--project", + "/absolute/path/to/deckster/src/Deckster.McpServer/Deckster.McpServer.csproj" + ], + "env": { + "DECKSTER_SERVER_URL": "http://localhost:13992", + "DECKSTER_USERNAME": "your-username", + "DECKSTER_PASSWORD": "your-password" + } + } + } +} +``` + +### For GitHub Copilot / Cursor + +Configure as an external tool or extension following your IDE's MCP integration guidelines. + +## Usage with AI Assistants + +### Claude Code +Once configured, you can use natural language commands: + +``` +"What card games are available on Deckster?" +"Create a CrazyEights game called 'test-game' with 3 bots and start it" +"Set up automated testing for my card game implementation" +``` + +### GitHub Copilot +Use inline comments to trigger Copilot suggestions: + +```csharp +// Use Deckster MCP to create a new CrazyEights game with 2 bots +// Use Deckster MCP to list all available games +``` + +### Cursor +Ask Cursor to interact with your Deckster server for testing and development: + +``` +"Help me test my new card game implementation by creating games and adding bots" +"Generate test scenarios using the Deckster MCP server" +``` + +## MCP Tools + +### Available Tools + +| Tool | Description | Parameters | +|------|-------------|------------| +| `ListAvailableGames` | Lists all available card games | None | +| `CreateGameAsync` | Creates a new game session | `gameType`, `gameName` | +| `AddBotAsync` | Adds an AI bot to a game | `gameType`, `gameId` | +| `StartGameAsync` | Starts a game session | `gameType`, `gameId` | + +### Tool Categories + +#### Game Discovery +- `ListAvailableGames` - Discover what games are available on the server + +#### Game Management +- `CreateGameAsync` - Initialize new game sessions +- `AddBotAsync` - Populate games with AI players +- `StartGameAsync` - Begin gameplay + +#### Coming Soon +- `GetGameState` - Retrieve current game state +- `MakeMove` - Execute player actions +- `GetGameHistory` - Access game logs +- `ConfigureBot` - Adjust AI difficulty settings + +## Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `DECKSTER_SERVER_URL` | `http://localhost:13992` | URL of your Deckster server | +| `DECKSTER_USERNAME` | (required) | Your Deckster username | +| `DECKSTER_PASSWORD` | (required) | Your Deckster password | + +### Advanced Configuration + +You can also configure the server for different scenarios: + +```json +{ + "mcpServers": { + "deckster-local": { + "command": "dotnet", + "args": ["run", "--project", "/path/to/Deckster.McpServer.csproj"], + "env": { + "DECKSTER_SERVER_URL": "http://localhost:13992", + "DECKSTER_USERNAME": "test-user", + "DECKSTER_PASSWORD": "test-pass" + } + }, + "deckster-remote": { + "command": "dotnet", + "args": ["run", "--project", "/path/to/Deckster.McpServer.csproj"], + "env": { + "DECKSTER_SERVER_URL": "https://deckster.example.com", + "DECKSTER_USERNAME": "prod-user", + "DECKSTER_PASSWORD": "prod-pass" + } + } + } +} +``` + +## Development + +### Running Tests + +```bash +cd src/Deckster.McpServer.Tests +dotnet test +``` + +### Project Structure + +``` +src/Deckster.McpServer/ +├── Program.cs # Entry point and MCP server setup +├── Configuration/ # Configuration models +│ └── DecksterConfiguration.cs +├── Tools/ # MCP tool implementations +│ └── DecksterGameTools.cs +├── Wrappers/ # Deckster client wrapper +│ └── DecksterClientWrapper.cs +└── README.md # This file +``` + +### Adding New Games + +To add support for more games: + +1. Implement the game-specific client in `Deckster.Client/Games/{GameName}/` +2. Add corresponding methods in `DecksterClientWrapper` +3. Update the `DecksterGameTools` with new MCP tool methods +4. Add tests in `Deckster.McpServer.Tests` + +## Troubleshooting + +### MCP client can't see the Deckster tools + +1. **Configuration Issues** + - Verify the configuration file is valid JSON (no trailing commas) + - Check the absolute path to the project is correct + - For Claude Code: Restart the application completely + - For VS Code/Copilot: Reload the window + +2. **Connection Errors** + ```bash + # Verify Deckster server is running + curl http://localhost:13992 + + # Test with correct credentials + dotnet run -- --test-connection + ``` + +3. **Authentication Failures** + - Credentials are case-sensitive + - Check for special characters that need escaping in JSON + - Verify account exists on Deckster server + +### Build Errors + +```bash +# Verify .NET version +dotnet --version # Should be 9.0+ + +# Clean rebuild +dotnet clean +dotnet restore +dotnet build + +# If packages fail to restore +dotnet nuget locals all --clear +``` + +### Runtime Issues + +1. **Port conflicts**: Ensure port 13992 is not in use by another service +2. **Firewall**: Allow localhost connections +3. **Permissions**: Ensure read/write access to project directory + +## Security Considerations + +The MCP server requires credentials to access your Deckster server. These credentials are: +- Stored in your local MCP configuration +- Never transmitted outside of localhost connections +- Used only for authenticated API calls to your Deckster server + +**Best Practices:** +- Use dedicated MCP accounts with limited permissions +- Rotate credentials regularly +- Never commit configuration files with credentials to version control + +## License + +Part of the Deckster platform. See main repository for license details. + +## Contributing + +We welcome contributions! Areas where you can help: + +1. **Add More Games** - Implement MCP tools for Bullshit, Idiot, Uno, etc. +2. **Enhance Tools** - Add game state queries, move execution, statistics +3. **Improve Testing** - Expand test coverage for edge cases +4. **Documentation** - Add examples and use cases + +See the main Deckster repository for contribution guidelines. + +## Resources + +- [Model Context Protocol Specification](https://github.com/modelcontextprotocol/specification) +- [Deckster Main Repository](https://github.com/yourusername/deckster) +- [MCP Server Examples](https://github.com/modelcontextprotocol/servers) + +## Support + +For issues or questions: +- Open an issue in this repository +- Check existing [documentation](../../docs/) +- Contact the Deckster team + +--- + +**Current Status**: MVP implementation with CrazyEights support. Actively expanding to support all Deckster games. \ No newline at end of file diff --git a/src/Deckster.McpServer/Tools/DecksterGameTools.cs b/src/Deckster.McpServer/Tools/DecksterGameTools.cs new file mode 100644 index 00000000..951182c9 --- /dev/null +++ b/src/Deckster.McpServer/Tools/DecksterGameTools.cs @@ -0,0 +1,200 @@ +using System.ComponentModel; +using Deckster.McpServer.Wrappers; +using ModelContextProtocol.Server; + +namespace Deckster.McpServer.Tools; + +[McpServerToolType] +public class DecksterGameTools +{ + private readonly DecksterClientWrapper? _clientWrapper; + + private static readonly string[] AvailableGames = + [ + "CrazyEights", + "Bullshit", + "Idiot", + "Uno", + "Yaniv", + "Gabong", + "TexasHoldEm" + ]; + + public DecksterGameTools(DecksterClientWrapper? clientWrapper = null) + { + _clientWrapper = clientWrapper; + } + + [McpServerTool] + [Description("Lists all available card games that can be played on Deckster server")] + public Task ListAvailableGames() => + Task.FromResult(string.Join("\n", AvailableGames)); + + [McpServerTool] + [Description("Creates a new game on the Deckster server. Returns the game ID.")] + public async Task CreateGameAsync(string gameType, string gameName) + { + if (_clientWrapper == null) + { + throw new InvalidOperationException("Client wrapper is not configured"); + } + + return await _clientWrapper.CreateGameAsync(gameType, gameName); + } + + [McpServerTool] + [Description("Adds an AI bot player to an existing game")] + public async Task AddBotAsync(string gameType, string gameId) + { + if (_clientWrapper == null) + { + throw new InvalidOperationException("Client wrapper is not configured"); + } + + await _clientWrapper.AddBotAsync(gameType, gameId); + } + + [McpServerTool] + [Description("Starts an existing game that has enough players")] + public async Task StartGameAsync(string gameType, string gameId) + { + if (_clientWrapper == null) + { + throw new InvalidOperationException("Client wrapper is not configured"); + } + + await _clientWrapper.StartGameAsync(gameType, gameId); + } + + [McpServerTool] + [Description("Lists previous/historical games for a specific game type. Returns game data as JSON.")] + public async Task GetPreviousGamesAsync(string gameType) + { + if (_clientWrapper == null) + { + throw new InvalidOperationException("Client wrapper is not configured"); + } + + return await _clientWrapper.GetPreviousGamesAsync(gameType); + } + + [McpServerTool] + [Description("Lists all currently active games for a specific game type. Returns game data as JSON.")] + public async Task GetActiveGamesAsync(string gameType) + { + if (_clientWrapper == null) + { + throw new InvalidOperationException("Client wrapper is not configured"); + } + + return await _clientWrapper.GetActiveGamesAsync(gameType); + } + + [McpServerTool] + [Description("Gets the current state of a specific game. Returns detailed game state as JSON.")] + public async Task GetGameStateAsync(string gameType, string gameName) + { + if (_clientWrapper == null) + { + throw new InvalidOperationException("Client wrapper is not configured"); + } + + return await _clientWrapper.GetGameStateAsync(gameType, gameName); + } + + [McpServerTool] + [Description("Deletes a game from the server. Use with caution as this permanently removes the game.")] + public async Task DeleteGameAsync(string gameType, string gameName) + { + if (_clientWrapper == null) + { + throw new InvalidOperationException("Client wrapper is not configured"); + } + + await _clientWrapper.DeleteGameAsync(gameType, gameName); + } + + [McpServerTool] + [Description("Gets an overview of a specific game type, including available games and statistics. Returns data as JSON.")] + public async Task GetGameOverviewAsync(string gameType) + { + if (_clientWrapper == null) + { + throw new InvalidOperationException("Client wrapper is not configured"); + } + + return await _clientWrapper.GetGameOverviewAsync(gameType); + } + + [McpServerTool] + [Description("Gets a specific historical/completed game by its ID. Returns full game history as JSON.")] + public async Task GetHistoricalGameAsync(string gameType, string gameId) + { + if (_clientWrapper == null) + { + throw new InvalidOperationException("Client wrapper is not configured"); + } + + return await _clientWrapper.GetHistoricalGameAsync(gameType, gameId); + } + + [McpServerTool] + [Description("Gets a specific version/snapshot of a historical game. Returns game state at that version as JSON.")] + public async Task GetHistoricalGameVersionAsync(string gameType, string gameId, int version) + { + if (_clientWrapper == null) + { + throw new InvalidOperationException("Client wrapper is not configured"); + } + + return await _clientWrapper.GetHistoricalGameVersionAsync(gameType, gameId, version); + } + + [McpServerTool] + [Description("Gets metadata information about a specific game type (rules, requirements, etc.). Returns metadata as JSON.")] + public async Task GetGameMetadataAsync(string gameType) + { + if (_clientWrapper == null) + { + throw new InvalidOperationException("Client wrapper is not configured"); + } + + return await _clientWrapper.GetGameMetadataAsync(gameType); + } + + [McpServerTool] + [Description("Gets a human-readable description of a specific game type including rules and how to play.")] + public async Task GetGameDescriptionAsync(string gameType) + { + if (_clientWrapper == null) + { + throw new InvalidOperationException("Client wrapper is not configured"); + } + + return await _clientWrapper.GetGameDescriptionAsync(gameType); + } + + [McpServerTool] + [Description("Gets information about the currently authenticated user. Returns user data as JSON.")] + public async Task GetCurrentUserAsync() + { + if (_clientWrapper == null) + { + throw new InvalidOperationException("Client wrapper is not configured"); + } + + return await _clientWrapper.GetCurrentUserAsync(); + } + + [McpServerTool] + [Description("Gets metadata about message types used in the Deckster system. Returns message metadata as JSON.")] + public async Task GetMessageMetadataAsync() + { + if (_clientWrapper == null) + { + throw new InvalidOperationException("Client wrapper is not configured"); + } + + return await _clientWrapper.GetMessageMetadataAsync(); + } +} diff --git a/src/Deckster.McpServer/Wrappers/DecksterClientWrapper.cs b/src/Deckster.McpServer/Wrappers/DecksterClientWrapper.cs new file mode 100644 index 00000000..99c16a10 --- /dev/null +++ b/src/Deckster.McpServer/Wrappers/DecksterClientWrapper.cs @@ -0,0 +1,254 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Deckster.Client; +using Deckster.Client.Games.CrazyEights; +using Deckster.McpServer.Configuration; + +namespace Deckster.McpServer.Wrappers; + +public class DecksterClientWrapper +{ + private readonly DecksterClient _client; + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + + public DecksterClientWrapper(DecksterClient client, HttpClient httpClient, string baseUrl) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _baseUrl = baseUrl ?? throw new ArgumentNullException(nameof(baseUrl)); + } + + public static async Task CreateAsync(DecksterConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + var client = await DecksterClient.LogInOrRegisterAsync( + configuration.ServerUrl, + configuration.Username, + configuration.Password); + + var httpClient = new HttpClient(); + + return new DecksterClientWrapper(client, httpClient, configuration.ServerUrl); + } + + public async Task CreateGameAsync(string gameType, string gameName) + { + ArgumentNullException.ThrowIfNull(gameType); + ArgumentNullException.ThrowIfNull(gameName); + + var gameInfo = gameType switch + { + "CrazyEights" => await _client.CrazyEights().CreateAsync(gameName), + _ => throw new ArgumentException($"Unknown game type: {gameType}", nameof(gameType)) + }; + + return gameInfo.Id; + } + + public async Task AddBotAsync(string gameType, string gameId) + { + ArgumentNullException.ThrowIfNull(gameType); + ArgumentNullException.ThrowIfNull(gameId); + + switch (gameType) + { + case "CrazyEights": + await _client.CrazyEights().AddBotAsync(gameId); + break; + default: + throw new ArgumentException($"Unknown game type: {gameType}", nameof(gameType)); + } + } + + public async Task StartGameAsync(string gameType, string gameId) + { + ArgumentNullException.ThrowIfNull(gameType); + ArgumentNullException.ThrowIfNull(gameId); + + switch (gameType) + { + case "CrazyEights": + await _client.CrazyEights().StartGameAsync(gameId); + break; + default: + throw new ArgumentException($"Unknown game type: {gameType}", nameof(gameType)); + } + } + + public async Task GetPreviousGamesAsync(string gameType) + { + ArgumentNullException.ThrowIfNull(gameType); + + var gameTypeLower = gameType.ToLowerInvariant(); + var url = $"{_baseUrl}/{gameTypeLower}/previousgames"; + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + + // Pretty-print JSON + var jsonDoc = JsonDocument.Parse(json); + return JsonSerializer.Serialize(jsonDoc, new JsonSerializerOptions { WriteIndented = true }); + } + + public async Task GetActiveGamesAsync(string gameType) + { + ArgumentNullException.ThrowIfNull(gameType); + + var gameTypeLower = gameType.ToLowerInvariant(); + var url = $"{_baseUrl}/{gameTypeLower}/games"; + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + + // Pretty-print JSON + var jsonDoc = JsonDocument.Parse(json); + return JsonSerializer.Serialize(jsonDoc, new JsonSerializerOptions { WriteIndented = true }); + } + + public async Task GetGameStateAsync(string gameType, string gameName) + { + ArgumentNullException.ThrowIfNull(gameType); + ArgumentNullException.ThrowIfNull(gameName); + + var gameTypeLower = gameType.ToLowerInvariant(); + var url = $"{_baseUrl}/{gameTypeLower}/games/{gameName}"; + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + + // Pretty-print JSON + var jsonDoc = JsonDocument.Parse(json); + return JsonSerializer.Serialize(jsonDoc, new JsonSerializerOptions { WriteIndented = true }); + } + + public async Task DeleteGameAsync(string gameType, string gameName) + { + ArgumentNullException.ThrowIfNull(gameType); + ArgumentNullException.ThrowIfNull(gameName); + + var gameTypeLower = gameType.ToLowerInvariant(); + var url = $"{_baseUrl}/{gameTypeLower}/games/{gameName}"; + + var response = await _httpClient.DeleteAsync(url); + response.EnsureSuccessStatusCode(); + } + + public async Task GetGameOverviewAsync(string gameType) + { + ArgumentNullException.ThrowIfNull(gameType); + + var gameTypeLower = gameType.ToLowerInvariant(); + var url = $"{_baseUrl}/{gameTypeLower}"; + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + + // Pretty-print JSON + var jsonDoc = JsonDocument.Parse(json); + return JsonSerializer.Serialize(jsonDoc, new JsonSerializerOptions { WriteIndented = true }); + } + + public async Task GetHistoricalGameAsync(string gameType, string gameId) + { + ArgumentNullException.ThrowIfNull(gameType); + ArgumentNullException.ThrowIfNull(gameId); + + var gameTypeLower = gameType.ToLowerInvariant(); + var url = $"{_baseUrl}/{gameTypeLower}/previousgames/{gameId}"; + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + + // Pretty-print JSON + var jsonDoc = JsonDocument.Parse(json); + return JsonSerializer.Serialize(jsonDoc, new JsonSerializerOptions { WriteIndented = true }); + } + + public async Task GetHistoricalGameVersionAsync(string gameType, string gameId, int version) + { + ArgumentNullException.ThrowIfNull(gameType); + ArgumentNullException.ThrowIfNull(gameId); + + var gameTypeLower = gameType.ToLowerInvariant(); + var url = $"{_baseUrl}/{gameTypeLower}/previousgames/{gameId}/{version}"; + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + + // Pretty-print JSON + var jsonDoc = JsonDocument.Parse(json); + return JsonSerializer.Serialize(jsonDoc, new JsonSerializerOptions { WriteIndented = true }); + } + + public async Task GetGameMetadataAsync(string gameType) + { + ArgumentNullException.ThrowIfNull(gameType); + + var gameTypeLower = gameType.ToLowerInvariant(); + var url = $"{_baseUrl}/{gameTypeLower}/metadata"; + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + + // Pretty-print JSON + var jsonDoc = JsonDocument.Parse(json); + return JsonSerializer.Serialize(jsonDoc, new JsonSerializerOptions { WriteIndented = true }); + } + + public async Task GetGameDescriptionAsync(string gameType) + { + ArgumentNullException.ThrowIfNull(gameType); + + var gameTypeLower = gameType.ToLowerInvariant(); + var url = $"{_baseUrl}/{gameTypeLower}/description"; + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStringAsync(); + } + + public async Task GetCurrentUserAsync() + { + var url = $"{_baseUrl}/me"; + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + + // Pretty-print JSON + var jsonDoc = JsonDocument.Parse(json); + return JsonSerializer.Serialize(jsonDoc, new JsonSerializerOptions { WriteIndented = true }); + } + + public async Task GetMessageMetadataAsync() + { + var url = $"{_baseUrl}/meta/messages"; + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + + // Pretty-print JSON + var jsonDoc = JsonDocument.Parse(json); + return JsonSerializer.Serialize(jsonDoc, new JsonSerializerOptions { WriteIndented = true }); + } +} diff --git a/src/Deckster.sln b/src/Deckster.sln index 7cb1e793..54ff2afc 100644 --- a/src/Deckster.sln +++ b/src/Deckster.sln @@ -49,6 +49,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "99. Other Clients", "99. Ot EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deckster.CardsAgainstHumanity.SampleClient", "Deckster.CardsAgainstHumanity.SampleClient\Deckster.CardsAgainstHumanity.SampleClient.csproj", "{D61DB3A9-F1A7-4DF4-8DAB-880F3551E005}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "69. MCP", "69. MCP", "{E2B58A61-452A-4B58-93DA-4DDB77BD9206}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deckster.McpServer", "Deckster.McpServer\Deckster.McpServer.csproj", "{FD8A525D-0E39-43CB-ACB4-9E6692D6BEAE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deckster.McpServer.Tests", "Deckster.McpServer.Tests\Deckster.McpServer.Tests.csproj", "{2708C9B6-4F39-4095-8716-01203B41137E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -123,6 +129,14 @@ Global {D61DB3A9-F1A7-4DF4-8DAB-880F3551E005}.Debug|Any CPU.Build.0 = Debug|Any CPU {D61DB3A9-F1A7-4DF4-8DAB-880F3551E005}.Release|Any CPU.ActiveCfg = Release|Any CPU {D61DB3A9-F1A7-4DF4-8DAB-880F3551E005}.Release|Any CPU.Build.0 = Release|Any CPU + {FD8A525D-0E39-43CB-ACB4-9E6692D6BEAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD8A525D-0E39-43CB-ACB4-9E6692D6BEAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD8A525D-0E39-43CB-ACB4-9E6692D6BEAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD8A525D-0E39-43CB-ACB4-9E6692D6BEAE}.Release|Any CPU.Build.0 = Release|Any CPU + {2708C9B6-4F39-4095-8716-01203B41137E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2708C9B6-4F39-4095-8716-01203B41137E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2708C9B6-4F39-4095-8716-01203B41137E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2708C9B6-4F39-4095-8716-01203B41137E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -145,6 +159,8 @@ Global {15FD6712-AFF7-42EC-BBAD-28E23B77107B} = {E66EE07A-1B20-4842-A77D-5A71F8FD3E0B} {2D7A1F3D-43D3-4030-B5FF-B2D53089F1CF} = {2AB8FFE7-41B8-45D8-8CE7-B3275ED3A0A2} {D61DB3A9-F1A7-4DF4-8DAB-880F3551E005} = {E66EE07A-1B20-4842-A77D-5A71F8FD3E0B} + {FD8A525D-0E39-43CB-ACB4-9E6692D6BEAE} = {E2B58A61-452A-4B58-93DA-4DDB77BD9206} + {2708C9B6-4F39-4095-8716-01203B41137E} = {E2B58A61-452A-4B58-93DA-4DDB77BD9206} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AE622423-92CC-4E60-ADDA-E76DEFF6CB96}