diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..78163a2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +# Git +.git +.gitignore +.gitattributes + +# Docker +Dockerfile* +docker-compose* +.dockerignore + +# Documentation +README.md +*.md +Assets/ + +# Build output +bin/ +obj/ +nupkg/ + +# IDE and Editor files +.vs/ +.vscode/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +*.log + +# Runtime +*.dll.config +*.exe.config + +# NuGet +*.nupkg +*.snupkg + +# User specific files +*.user +*.suo + +# Temporary files +*.tmp +*.temp \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..31bfd4b --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,86 @@ +name: Build and Publish Docker Image + +on: + push: + branches: + - main + - dev + tags: + - 'v*' + pull_request: + branches: + - main + - dev + workflow_dispatch: + inputs: + publish_branch: + description: 'Publish branch-specific image' + required: false + default: false + type: boolean + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write # Required for OIDC token generation + attestations: write # Required to create attestations + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + # Branch-based tags + type=ref,event=branch + # Tag-based versioning + type=ref,event=tag + # Semver patterns for tags + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + # Latest tag for main branch + type=raw,value=latest,enable={{is_default_branch}} + # Dev tag for dev branch + type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} + + - name: Build and push Docker image + id: build + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish_branch == 'true') }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1 + if: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish_branch == 'true') }} + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 89e277d..e8ea849 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ # Auto-generated code editor files .vs/* .vscode/* -*.csproj obj bin Properties/* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e226be2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +# Use the official .NET 9.0 SDK image for building +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# Copy project file and restore dependencies (better layer caching) +COPY ["modelcontextprotocol.csproj", "./"] +RUN dotnet restore "modelcontextprotocol.csproj" --runtime linux-x64 + +# Copy source code and build the application +COPY . . +RUN dotnet publish "modelcontextprotocol.csproj" -c Release -o /app/publish \ + --no-restore \ + --self-contained true \ + --runtime linux-x64 \ + /p:PublishTrimmed=false + +# Use a minimal base image for self-contained deployment +FROM mcr.microsoft.com/dotnet/runtime-deps:9.0 AS runtime + +# Install curl for health checks +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Create a non-root user for security +RUN adduser --disabled-password --gecos '' --uid 1001 appuser && \ + chown -R appuser:appuser /app +USER appuser + +# Copy the published application from the build stage +COPY --from=build /app/publish . + +# Expose the port the app runs on +EXPOSE 8080 + +# Set environment variables for ASP.NET Core +ENV ASPNETCORE_URLS=http://0.0.0.0:8080 \ + ASPNETCORE_ENVIRONMENT=Production \ + DOTNET_RUNNING_IN_CONTAINER=true \ + DOTNET_USE_POLLING_FILE_WATCHER=true + +# Add health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# Run the self-contained application +ENTRYPOINT ["./SandboxModelContextProtocol.Server"] \ No newline at end of file diff --git a/Program.cs b/Program.cs index 1a2cfbf..a5c4621 100644 --- a/Program.cs +++ b/Program.cs @@ -1,10 +1,15 @@ +using System; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using SandboxModelContextProtocol.Server.Services; using SandboxModelContextProtocol.Server.Services.Interfaces; using SandboxModelContextProtocol.Server.Services.Models; +using Microsoft.Extensions.Configuration; namespace SandboxModelContextProtocol.Server; @@ -12,33 +17,62 @@ public class Program { public static async Task Main( string[] args ) { - var builder = Host.CreateApplicationBuilder( args ); + var builder = WebApplication.CreateBuilder( args ); - // Configure logging to go to stderr for MCP protocol compliance - builder.Logging.AddConsole( consoleLogOptions => - { - consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace; - } ); + builder.WebHost.UseUrls( "http://0.0.0.0:8080" ); + + // Configure logging for HTTP transport + builder.Logging.AddConsole(); // Configure WebSocket options - builder.Services.Configure( + builder.Services.Configure( builder.Configuration.GetSection( "WebSocket" ) ); // Register the command service - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); - // Configure MCP Server with stdio transport and tools from assembly + // Register the resource service + builder.Services.AddSingleton(); + + // Configure MCP Server with HTTP transport, tools and resources from assembly builder.Services .AddMcpServer() - .WithStdioServerTransport() - .WithToolsFromAssembly(); + .WithHttpTransport() + .WithToolsFromAssembly() + .WithResourcesFromAssembly(); - // Register WebSocket service as both hosted service and singleton for interface + // Register WebSocket service as singleton (no longer a hosted service) builder.Services.AddSingleton(); builder.Services.AddSingleton( provider => provider.GetRequiredService() ); - builder.Services.AddHostedService( provider => provider.GetRequiredService() ); - await builder.Build().RunAsync(); + var app = builder.Build(); + + // Enable WebSocket support + app.UseWebSockets(); + + // Map MCP endpoints for HTTP transport + app.MapMcp(); + + // Add health check endpoint for Docker + app.MapGet( "/health", () => Results.Ok( new { status = "healthy", timestamp = DateTime.UtcNow } ) ); + + // Configure WebSocket endpoint + var webSocketService = app.Services.GetRequiredService(); + + app.Map( "/ws", async context => + { + if ( context.WebSockets.IsWebSocketRequest ) + { + using var webSocket = await context.WebSockets.AcceptWebSocketAsync(); + await webSocketService.HandleWebSocketConnection( webSocket, context.RequestAborted ); + } + else + { + context.Response.StatusCode = 400; + } + } ); + + await app.RunAsync(); } } diff --git a/README.md b/README.md index b502393..8b41caf 100644 --- a/README.md +++ b/README.md @@ -14,73 +14,63 @@ This MCP server works in conjunction with the separate [s&box Adapter Library](h ## Prerequisites -- [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet/9.0) or later -- [s&box](https://sbox.game/) (latest version) -- [s&box Adapter Library](https://github.com/suiramdev/sbox-mcp-library) (must be installed separately) -- An MCP-compatible AI assistant (Claude Desktop, Cursor, etc.) +- [Docker](https://www.docker.com/get-started) (recommended) or [.NET SDK](https://dotnet.microsoft.com/download) (for manual installation) +- [s&box](https://sbox.game/) +- An [AI assistant that supports MCP](https://docs.cursor.com/mcp/introduction) (for example, Cursor, Claude Desktop, etc.) is suggested ## Quick Start ### Step 1: Install and Run the MCP Server -1. **Clone this repository** +To get started, make sure you have Docker installed on your system. Cursor users can simply click the button below to install and launch the MCP Server automatically, or you can follow the manual instructions below. + + +1. **Run the container** ```bash - git clone https://github.com/suiramdev/sbox-mcp-server.git - cd sbox-mcp-server + docker run -d -p 8080:8080 --name sbox-mcp-server ghcr.io/suiramdev/sbox-mcp-server:latest ``` -2. **Build the server** +2. **Connect your AI assistant to the running MCP Server** - **Using the Build Script (Recommended):** + [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=sbox-mcp-server&config=JTdCJTIydHlwZSUyMiUzQSUyMmh0dHAlMjIlMkMlMjJ1cmwlMjIlM0ElMjJodHRwJTNBJTJGJTJGbG9jYWxob3N0JTNBODA4MCUyMiU3RA%3D%3D) - ```powershell - .\build.ps1 - ``` - **Manual Build:** +
+Manual Installation using .NET SDK + +
+ +1. **Build the server** ```bash - dotnet build + dotnet build ``` -3. **Configure your AI assistant** - - Add the MCP server to your AI assistant configuration: - - **For Cursor Editor (mcp.json):** - - ```json - { - "mcpServers": { - "sbox": { - "command": "cmd", - "type": "stdio", - "enable": true, - "args": [ - "/c", - "\\bin\\win-x64\\SandboxModelContextProtocol.Server.exe" - ] - } - } - } +2. **Run the server** + + ```bash + dotnet run ``` -> [!IMPORTANT] -> The server must be running for the s&box adapter library to function. Please ensure the server is running before proceeding to the next step. +3. **Connect your AI assistant to the running MCP Server** + + [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=sbox-mcp-server&config=JTdCJTIydHlwZSUyMiUzQSUyMmh0dHAlMjIlMkMlMjJ1cmwlMjIlM0ElMjJodHRwJTNBJTJGJTJGbG9jYWxob3N0JTNBODA4MCUyMiU3RA%3D%3D) + +
### Step 2: Install the Adapter Library in s&box -Before you can interact with s&box, you must install the adapter library: +To enable interaction with s&box, you need to install the adapter library. This library allows the MCP Server to communicate with the s&box editor. 1. **Install the Adapter Library** from the [sbox-mcp-library repository](https://github.com/suiramdev/sbox-mcp-library) + 2. **Follow the setup instructions** in the Adapter Library repository to: - Install the library in your s&box project through the Asset Library - Connect to this MCP Server -3. **Ensure both components are connected** before using AI assistant commands > [!IMPORTANT] -> This MCP Server requires the separate [s&box Adapter Library](https://github.com/suiramdev/sbox-mcp-library) to communicate with the s&box editor. Please refer to the [Adapter Library documentation](https://github.com/suiramdev/sbox-mcp-library) for detailed installation and usage instructions. +> The server must be running for the s&box adapter library to function. Please ensure the server is running before proceeding to the next step. ## Usage @@ -94,22 +84,6 @@ Once both this MCP Server and the s&box Adapter Library are installed and connec "Show me all components attached to the Ground object" ``` -## Troubleshooting - -### Server Connection Issues - -If the MCP Server fails to start: - -1. **Verify .NET 9.0 SDK is installed** and accessible via command line -2. **Check the console output** for error messages -3. **Ensure port 8080 is available** (or configure a different port in appsettings.json) - -### Testing the Server - -You can manually test if the MCP Server is running by: -- Using [Postman](https://www.postman.com/downloads/) or similar API clients to send WebSocket requests to `ws://localhost:8080/ws` -- Checking the server console output for connection attempts - ## Architecture This MCP Server acts as a bridge between: @@ -121,38 +95,6 @@ The MCP Server: - Sends commands to the s&box Adapter Library for execution - Returns responses back to AI assistants -## Configuration - -The server can be configured via `appsettings.json`: - -```json -{ - "WebSocket": { - "Url": "http://localhost:8080", - "Path": "/ws" - } -} -``` - -> **Note**: Port 8080 is the recommended WebSocket port for s&box local development. - -## Build Script Options - -The included PowerShell build script (`build.ps1`) provides comprehensive build management: - -| Command | Description | -| ------------------------- | ------------------------------------- | -| `.\build.ps1` | Default build (Release configuration) | -| `.\build.ps1 build` | Build in Release mode | -| `.\build.ps1 build-debug` | Build in Debug mode | -| `.\build.ps1 run` | Run the server | -| `.\build.ps1 run-debug` | Run in debug mode | -| `.\build.ps1 publish` | Create self-contained executable | -| `.\build.ps1 clean` | Clean build artifacts | -| `.\build.ps1 rebuild` | Full clean rebuild | -| `.\build.ps1 test` | Run unit tests | -| `.\build.ps1 help` | Show all available options | - ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change. diff --git a/Resources/.gitkeep b/Resources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Services/EditorResourceService.cs b/Services/EditorResourceService.cs new file mode 100644 index 0000000..4f5544d --- /dev/null +++ b/Services/EditorResourceService.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SandboxModelContextProtocol.Server.Services.Interfaces; +using SandboxModelContextProtocol.Server.Services.Models; + +namespace SandboxModelContextProtocol.Server.Services; + +public class ResourceService( ILogger logger, IServiceProvider serviceProvider ) : IResourceService +{ + private readonly ILogger _logger = logger; + private readonly ConcurrentDictionary> _pendingRequests = new(); + private readonly IWebSocketService _webSocketService = serviceProvider.GetRequiredService(); + + public async Task GetResource( CallResourceRequest request ) + { + _logger.LogInformation( "Getting resource: {Name}", request.Name ); + + // Find active connections + var activeConnections = _webSocketService.GetWebSocketConnections() + .Where( conn => conn.IsConnected ) + .ToList(); + + // If no active connections, return an error + if ( activeConnections.Count == 0 ) + { + return new CallResourceResponse() + { + Id = request.Id, + Name = request.Name, + Content = [JsonSerializer.SerializeToElement( "No active s&box connections available" )], + IsError = true + }; + } + + // Add command ID to request + var requestJson = JsonSerializer.Serialize( request ); + + // Create task completion source for this request + var tcs = new TaskCompletionSource(); + _pendingRequests[request.Id] = tcs; + + try + { + // Send request to all active s&box connections + await _webSocketService.SendToAll( requestJson ); + + _logger.LogInformation( "Resource request sent to s&box connections" ); + + // Wait for response with timeout + using var cts = new CancellationTokenSource( TimeSpan.FromSeconds( 30 ) ); + cts.Token.Register( () => + { + if ( tcs.TrySetCanceled() ) + { + _pendingRequests.TryRemove( request.Id, out _ ); + _logger.LogWarning( "Resource request {Id} timed out", request.Id ); + } + } ); + + return await tcs.Task; + } + catch ( OperationCanceledException ) + { + _pendingRequests.TryRemove( request.Id, out _ ); + return new CallResourceResponse() + { + Id = request.Id, + Name = request.Name, + Content = [JsonSerializer.SerializeToElement( "Resource request timed out after 30 seconds" )], + IsError = true + }; + } + catch ( Exception ex ) + { + _pendingRequests.TryRemove( request.Id, out _ ); + _logger.LogError( ex, "Failed to send resource request to s&box connections" ); + return new CallResourceResponse() + { + Id = request.Id, + Name = request.Name, + Content = [JsonSerializer.SerializeToElement( $"Failed to send resource request: {ex.Message}" )], + IsError = true + }; + } + } + + public void HandleResponse( string message ) + { + _logger.LogInformation( "Handling resource response: {Message}", message ); + + CallResourceResponse? response = JsonSerializer.Deserialize( message ); + if ( response == null ) + { + _logger.LogWarning( "Failed to parse resource response JSON: {Message}", message ); + return; + } + + if ( _pendingRequests.TryRemove( response.Id, out var tcs ) ) + { + tcs.SetResult( response ); + _logger.LogInformation( "Resource request {Id} completed successfully", response.Id ); + } + } +} diff --git a/Services/Interfaces/IResourceService.cs b/Services/Interfaces/IResourceService.cs new file mode 100644 index 0000000..8811c79 --- /dev/null +++ b/Services/Interfaces/IResourceService.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using SandboxModelContextProtocol.Server.Services.Models; + +namespace SandboxModelContextProtocol.Server.Services.Interfaces; + +public interface IResourceService +{ + Task GetResource( CallResourceRequest request ); + void HandleResponse( string message ); +} diff --git a/Services/Interfaces/IEditorToolService.cs b/Services/Interfaces/IToolService.cs similarity index 64% rename from Services/Interfaces/IEditorToolService.cs rename to Services/Interfaces/IToolService.cs index 42b3add..d14bd4b 100644 --- a/Services/Interfaces/IEditorToolService.cs +++ b/Services/Interfaces/IToolService.cs @@ -3,8 +3,8 @@ namespace SandboxModelContextProtocol.Server.Services.Interfaces; -public interface IEditorToolService +public interface IToolService { - Task CallTool( CallEditorToolRequest request ); + Task CallTool( CallToolRequest request ); void HandleResponse( string response ); } diff --git a/Services/Models/CallResourceRequest.cs b/Services/Models/CallResourceRequest.cs new file mode 100644 index 0000000..e974966 --- /dev/null +++ b/Services/Models/CallResourceRequest.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SandboxModelContextProtocol.Server.Services.Models; + +public class CallResourceRequest +{ + [JsonIgnore] + public string Type { get; set; } = "resource"; + + [JsonPropertyName( "id" )] + public string Id { get; set; } = Guid.NewGuid().ToString(); + + [JsonPropertyName( "name" )] + public required string Name { get; init; } + + [JsonPropertyName( "arguments" )] + public Dictionary Arguments { get; set; } = new(); +} diff --git a/Services/Models/CallResourceResponse.cs b/Services/Models/CallResourceResponse.cs new file mode 100644 index 0000000..303f346 --- /dev/null +++ b/Services/Models/CallResourceResponse.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SandboxModelContextProtocol.Server.Services.Models; + +public class CallResourceResponse +{ + [JsonPropertyName( "type" )] + public string Type { get; } = "resource"; + + [JsonPropertyName( "id" )] + public required string Id { get; set; } + + [JsonPropertyName( "name" )] + public required string Name { get; set; } + + [JsonPropertyName( "content" )] + public List Content { get; set; } = []; + + [JsonPropertyName( "isError" )] + public bool IsError { get; set; } +} diff --git a/Services/Models/CallEditorToolRequest.cs b/Services/Models/CallToolRequest.cs similarity index 81% rename from Services/Models/CallEditorToolRequest.cs rename to Services/Models/CallToolRequest.cs index 4da10ed..9f2e846 100644 --- a/Services/Models/CallEditorToolRequest.cs +++ b/Services/Models/CallToolRequest.cs @@ -5,8 +5,11 @@ namespace SandboxModelContextProtocol.Server.Services.Models; -public class CallEditorToolRequest +public class CallToolRequest { + [JsonPropertyName( "type" )] + public string Type { get; } = "tool"; + [JsonPropertyName( "id" )] public string Id { get; set; } = Guid.NewGuid().ToString(); @@ -15,4 +18,4 @@ public class CallEditorToolRequest [JsonPropertyName( "arguments" )] public IReadOnlyDictionary? Arguments { get; init; } -} \ No newline at end of file +} diff --git a/Services/Models/CallEditorToolResponse.cs b/Services/Models/CallToolResponse.cs similarity index 83% rename from Services/Models/CallEditorToolResponse.cs rename to Services/Models/CallToolResponse.cs index aed75e6..b15f523 100644 --- a/Services/Models/CallEditorToolResponse.cs +++ b/Services/Models/CallToolResponse.cs @@ -4,8 +4,11 @@ namespace SandboxModelContextProtocol.Server.Services.Models; -public class CallEditorToolResponse +public class CallToolResponse { + [JsonIgnore] + public string Type { get; set; } = "tool"; + [JsonPropertyName( "id" )] public required string Id { get; set; } @@ -17,4 +20,4 @@ public class CallEditorToolResponse [JsonPropertyName( "isError" )] public bool IsError { get; set; } -} \ No newline at end of file +} diff --git a/Services/EditorToolService.cs b/Services/ToolService.cs similarity index 68% rename from Services/EditorToolService.cs rename to Services/ToolService.cs index e39ba5a..db3631d 100644 --- a/Services/EditorToolService.cs +++ b/Services/ToolService.cs @@ -11,17 +11,14 @@ namespace SandboxModelContextProtocol.Server.Services; -public class EditorToolService( ILogger logger, IServiceProvider serviceProvider ) : IEditorToolService +public class ToolService( ILogger logger, IServiceProvider serviceProvider ) : IToolService { - private readonly ILogger _logger = logger; - private readonly ConcurrentDictionary> _pendingCommands = new(); + private readonly ILogger _logger = logger; + private readonly ConcurrentDictionary> _pendingCommands = new(); private readonly IWebSocketService _webSocketService = serviceProvider.GetRequiredService(); - public async Task CallTool( CallEditorToolRequest request ) + public async Task CallTool( CallToolRequest request ) { - // Generate unique command ID - var id = Guid.NewGuid().ToString(); - _logger.LogInformation( "Executing tool call: {Name}", request.Name ); // Find active connections @@ -32,27 +29,25 @@ public async Task CallTool( CallEditorToolRequest reques // If no active connections, return an error if ( activeConnections.Count == 0 ) { - return new CallEditorToolResponse() + return new CallToolResponse() { - Id = id, + Id = request.Id, Name = request.Name, Content = [JsonSerializer.SerializeToElement( "No active s&box connections available" )], IsError = true }; } - // Add command ID to request - request.Id = id; - var commandJson = JsonSerializer.Serialize( request ); + var requestJson = JsonSerializer.Serialize( request ); // Create task completion source for this command - var tcs = new TaskCompletionSource(); - _pendingCommands[id] = tcs; + var tcs = new TaskCompletionSource(); + _pendingCommands[request.Id] = tcs; try { // Send command to all active s&box connections - await _webSocketService.SendToAll( commandJson ); + await _webSocketService.SendToAll( requestJson ); _logger.LogInformation( "Tool call sent to s&box connections" ); @@ -62,8 +57,8 @@ public async Task CallTool( CallEditorToolRequest reques { if ( tcs.TrySetCanceled() ) { - _pendingCommands.TryRemove( id, out _ ); - _logger.LogWarning( "Tool call {Id} timed out", id ); + _pendingCommands.TryRemove( request.Id, out _ ); + _logger.LogWarning( "Tool call {Id} timed out", request.Id ); } } ); @@ -71,10 +66,10 @@ public async Task CallTool( CallEditorToolRequest reques } catch ( OperationCanceledException ) { - _pendingCommands.TryRemove( id, out _ ); - return new CallEditorToolResponse() + _pendingCommands.TryRemove( request.Id, out _ ); + return new CallToolResponse() { - Id = id, + Id = request.Id, Name = request.Name, Content = [JsonSerializer.SerializeToElement( "Tool call timed out after 30 seconds" )], IsError = true @@ -82,11 +77,11 @@ public async Task CallTool( CallEditorToolRequest reques } catch ( Exception ex ) { - _pendingCommands.TryRemove( id, out _ ); + _pendingCommands.TryRemove( request.Id, out _ ); _logger.LogError( ex, "Failed to send tool call to s&box connections" ); - return new CallEditorToolResponse() + return new CallToolResponse() { - Id = id, + Id = request.Id, Name = request.Name, Content = [JsonSerializer.SerializeToElement( $"Failed to send tool call: {ex.Message}" )], IsError = true @@ -98,7 +93,7 @@ public void HandleResponse( string message ) { _logger.LogInformation( "Handling response: {Message}", message ); - CallEditorToolResponse? response = JsonSerializer.Deserialize( message ); + CallToolResponse? response = JsonSerializer.Deserialize( message ); if ( response == null ) { _logger.LogWarning( "Failed to parse response JSON: {Message}", message ); diff --git a/Services/WebSocketService.cs b/Services/WebSocketService.cs index 50438e4..c77940e 100644 --- a/Services/WebSocketService.cs +++ b/Services/WebSocketService.cs @@ -61,7 +61,7 @@ public async Task StartAsync( CancellationToken cancellationToken ) } ); await _app.StartAsync( cancellationToken ); - _logger.LogInformation( "WebSocket Server started on {WebSocketUrl}{WebSocketPath}", _options.Url, _options.Path ); + _logger.LogInformation( "WebSocket Server started on {WebSocketPath}", _options.Path ); } /// @@ -123,7 +123,7 @@ private void UnregisterWebSocketConnection( WebSocketConnection connection ) _logger.LogInformation( "WebSocket connection unregistered: {ConnectionId}", connectionId ); } - private async Task HandleWebSocketConnection( WebSocket webSocket, CancellationToken cancellationToken ) + public async Task HandleWebSocketConnection( WebSocket webSocket, CancellationToken cancellationToken ) { var connection = new WebSocketConnection( webSocket, _logger ); RegisterWebSocketConnection( connection ); @@ -151,7 +151,7 @@ private async Task HandleWebSocketConnection( WebSocket webSocket, CancellationT _logger.LogInformation( "Received from s&box: {Message}", message ); // Handle responses from s&box - var commandService = _serviceProvider.GetRequiredService(); + var commandService = _serviceProvider.GetRequiredService(); commandService.HandleResponse( message ); } else if ( result.MessageType == WebSocketMessageType.Close ) diff --git a/Tools/EditorSceneTool.cs b/Tools/EditorSceneTool.cs index 2cf0e11..ac96361 100644 --- a/Tools/EditorSceneTool.cs +++ b/Tools/EditorSceneTool.cs @@ -9,14 +9,14 @@ namespace SandboxModelContextProtocol.Server.Tools; [McpServerToolType] -public class EditorSceneTool( IEditorToolService editorToolService ) +public class EditorSceneTool( IToolService editorToolService ) { - private readonly IEditorToolService _editorToolService = editorToolService; + private readonly IToolService _editorToolService = editorToolService; [McpServerTool, Description( "Gets the scene in the active editor session." )] - public async Task GetActiveEditorScene() + public async Task GetActiveEditorScene() { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( GetActiveEditorScene ), }; @@ -25,9 +25,9 @@ public async Task GetActiveEditorScene() } [McpServerTool, Description( "Loads a scene in a new editor session from a path." )] - public async Task LoadEditorSceneFromPath( string path ) + public async Task LoadEditorSceneFromPath( string path ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( LoadEditorSceneFromPath ), Arguments = new Dictionary() diff --git a/Tools/EditorSessionTool.cs b/Tools/EditorSessionTool.cs index 96f248f..9cf6270 100644 --- a/Tools/EditorSessionTool.cs +++ b/Tools/EditorSessionTool.cs @@ -7,51 +7,51 @@ namespace SandboxModelContextProtocol.Server.Tools; [McpServerToolType] -public class EditorSessionTool( IEditorToolService editorToolService ) +public class EditorSessionTool( IToolService toolService ) { - private readonly IEditorToolService _editorToolService = editorToolService; + private readonly IToolService _toolService = toolService; [McpServerTool, Description( "Gets the active editor session." )] - public async Task GetActiveEditorSession() + public async Task GetActiveEditorSession() { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( GetActiveEditorSession ), }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Gets all editor sessions." )] - public async Task GetAllEditorSessions() + public async Task GetAllEditorSessions() { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( GetAllEditorSessions ), }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Saves all editor sessions." )] - public async Task SaveAllEditorSessions() + public async Task SaveAllEditorSessions() { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( SaveAllEditorSessions ), }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Saves the active editor session." )] - public async Task SaveActiveEditorSession() + public async Task SaveActiveEditorSession() { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( SaveActiveEditorSession ), }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } -} \ No newline at end of file +} diff --git a/Tools/GameObjectTool.cs b/Tools/GameObjectTool.cs index e9574ba..8edc3a4 100644 --- a/Tools/GameObjectTool.cs +++ b/Tools/GameObjectTool.cs @@ -9,14 +9,14 @@ namespace SandboxModelContextProtocol.Server.Tools; [McpServerToolType] -public class GameObjectTool( IEditorToolService editorToolService ) +public class GameObjectTool( IToolService toolService ) { - private readonly IEditorToolService _editorToolService = editorToolService; + private readonly IToolService _toolService = toolService; [McpServerTool, Description( "Gets a game object by name." )] - public async Task GetGameObjectByName( string name, string? sceneId = null ) + public async Task GetGameObjectByName( string name, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( GetGameObjectByName ), Arguments = new Dictionary() @@ -26,13 +26,13 @@ public async Task GetGameObjectByName( string name, stri }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Gets a game object by id." )] - public async Task GetGameObjectById( string id, string? sceneId = null ) + public async Task GetGameObjectById( string id, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( GetGameObjectById ), Arguments = new Dictionary() @@ -42,13 +42,13 @@ public async Task GetGameObjectById( string id, string? }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Gets all game objects." )] - public async Task GetAllGameObjects( string? sceneId = null ) + public async Task GetAllGameObjects( string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( GetAllGameObjects ), Arguments = new Dictionary() @@ -57,13 +57,13 @@ public async Task GetAllGameObjects( string? sceneId = n }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Creates a new game object." )] - public async Task CreateGameObject( string name, string sceneId, string? parentId = null ) + public async Task CreateGameObject( string name, string sceneId, string? parentId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( CreateGameObject ), Arguments = new Dictionary() @@ -74,13 +74,13 @@ public async Task CreateGameObject( string name, string }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Duplicates a game object." )] - public async Task DuplicateGameObject( string id, string? sceneId = null, string? parentId = null ) + public async Task DuplicateGameObject( string id, string? sceneId = null, string? parentId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( DuplicateGameObject ), Arguments = new Dictionary() @@ -91,13 +91,13 @@ public async Task DuplicateGameObject( string id, string }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Destroys (deletes) a game object." )] - public async Task DestroyGameObject( string id, string? sceneId = null ) + public async Task DestroyGameObject( string id, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( DestroyGameObject ), Arguments = new Dictionary() @@ -107,14 +107,14 @@ public async Task DestroyGameObject( string id, string? }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } // Transform Commands [McpServerTool, Description( "Sets the world position of a game object." )] - public async Task SetGameObjectWorldPosition( string id, float x, float y, float z, string? sceneId = null ) + public async Task SetGameObjectWorldPosition( string id, float x, float y, float z, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( SetGameObjectWorldPosition ), Arguments = new Dictionary() @@ -127,13 +127,13 @@ public async Task SetGameObjectWorldPosition( string id, }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Sets the world rotation of a game object." )] - public async Task SetGameObjectWorldRotation( string id, float x, float y, float z, float w, string? sceneId = null ) + public async Task SetGameObjectWorldRotation( string id, float x, float y, float z, float w, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( SetGameObjectWorldRotation ), Arguments = new Dictionary() @@ -147,13 +147,13 @@ public async Task SetGameObjectWorldRotation( string id, }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Sets the world scale of a game object." )] - public async Task SetGameObjectWorldScale( string id, float x, float y, float z, string? sceneId = null ) + public async Task SetGameObjectWorldScale( string id, float x, float y, float z, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( SetGameObjectWorldScale ), Arguments = new Dictionary() @@ -166,13 +166,13 @@ public async Task SetGameObjectWorldScale( string id, fl }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Sets the local position of a game object." )] - public async Task SetGameObjectLocalPosition( string id, float x, float y, float z, string? sceneId = null ) + public async Task SetGameObjectLocalPosition( string id, float x, float y, float z, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( SetGameObjectLocalPosition ), Arguments = new Dictionary() @@ -185,13 +185,13 @@ public async Task SetGameObjectLocalPosition( string id, }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Sets the local rotation of a game object." )] - public async Task SetGameObjectLocalRotation( string id, float x, float y, float z, float w, string? sceneId = null ) + public async Task SetGameObjectLocalRotation( string id, float x, float y, float z, float w, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( SetGameObjectLocalRotation ), Arguments = new Dictionary() @@ -205,13 +205,13 @@ public async Task SetGameObjectLocalRotation( string id, }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Sets the local scale of a game object." )] - public async Task SetGameObjectLocalScale( string id, float x, float y, float z, string? sceneId = null ) + public async Task SetGameObjectLocalScale( string id, float x, float y, float z, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( SetGameObjectLocalScale ), Arguments = new Dictionary() @@ -224,14 +224,14 @@ public async Task SetGameObjectLocalScale( string id, fl }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } // Hierarchy Commands [McpServerTool, Description( "Sets the parent of a game object." )] - public async Task SetGameObjectParent( string id, string? parentId, string? sceneId = null ) + public async Task SetGameObjectParent( string id, string? parentId, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( SetGameObjectParent ), Arguments = new Dictionary() @@ -242,13 +242,13 @@ public async Task SetGameObjectParent( string id, string }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Gets all child game objects of a game object." )] - public async Task GetGameObjectChildren( string id, string? sceneId = null ) + public async Task GetGameObjectChildren( string id, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( GetGameObjectChildren ), Arguments = new Dictionary() @@ -258,13 +258,13 @@ public async Task GetGameObjectChildren( string id, stri }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Gets the parent of a game object." )] - public async Task GetGameObjectParent( string id, string? sceneId = null ) + public async Task GetGameObjectParent( string id, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( GetGameObjectParent ), Arguments = new Dictionary() @@ -274,14 +274,14 @@ public async Task GetGameObjectParent( string id, string }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } // Property Commands [McpServerTool, Description( "Sets the name of a game object." )] - public async Task SetGameObjectName( string id, string name, string? sceneId = null ) + public async Task SetGameObjectName( string id, string name, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( SetGameObjectName ), Arguments = new Dictionary() @@ -292,13 +292,13 @@ public async Task SetGameObjectName( string id, string n }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Sets the enabled state of a game object." )] - public async Task SetGameObjectEnabled( string id, bool enabled, string? sceneId = null ) + public async Task SetGameObjectEnabled( string id, bool enabled, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( SetGameObjectEnabled ), Arguments = new Dictionary() @@ -309,14 +309,14 @@ public async Task SetGameObjectEnabled( string id, bool }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } // Component Commands [McpServerTool, Description( "Adds a component to a game object." )] - public async Task AddGameObjectComponent( string id, string componentType, string? sceneId = null ) + public async Task AddGameObjectComponent( string id, string componentType, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( AddGameObjectComponent ), Arguments = new Dictionary() @@ -327,13 +327,13 @@ public async Task AddGameObjectComponent( string id, str }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Removes a component from a game object." )] - public async Task RemoveGameObjectComponent( string id, string componentType, string? sceneId = null ) + public async Task RemoveGameObjectComponent( string id, string componentType, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( RemoveGameObjectComponent ), Arguments = new Dictionary() @@ -344,13 +344,13 @@ public async Task RemoveGameObjectComponent( string id, }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Gets all components of a game object." )] - public async Task GetGameObjectComponents( string id, string? sceneId = null ) + public async Task GetGameObjectComponents( string id, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( GetGameObjectComponents ), Arguments = new Dictionary() @@ -360,13 +360,13 @@ public async Task GetGameObjectComponents( string id, st }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Gets a specific component of a game object." )] - public async Task GetGameObjectComponent( string id, string componentType, string? sceneId = null ) + public async Task GetGameObjectComponent( string id, string componentType, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( GetGameObjectComponent ), Arguments = new Dictionary() @@ -377,13 +377,13 @@ public async Task GetGameObjectComponent( string id, str }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } [McpServerTool, Description( "Sets properties of a component on a game object." )] - public async Task SetGameObjectComponentProperty( string id, string componentType, string propertyName, JsonElement value, string? sceneId = null ) + public async Task SetGameObjectComponentProperty( string id, string componentType, string propertyName, JsonElement value, string? sceneId = null ) { - var command = new CallEditorToolRequest() + var command = new CallToolRequest() { Name = nameof( SetGameObjectComponentProperty ), Arguments = new Dictionary() @@ -396,6 +396,6 @@ public async Task SetGameObjectComponentProperty( string }, }; - return await _editorToolService.CallTool( command ); + return await _toolService.CallTool( command ); } } diff --git a/appsettings.json b/appsettings.json index 92e8235..60d7fe8 100644 --- a/appsettings.json +++ b/appsettings.json @@ -1,6 +1,17 @@ { + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Console": { + "IncludeScopes": false, + "TimestampFormat": "HH:mm:ss " + } + }, "WebSocket": { - "Url": "http://localhost:8080", + "Url": "http://+:8080", "Path": "/ws" - } + }, + "AllowedHosts": "*" } \ No newline at end of file diff --git a/build.ps1 b/build.ps1 deleted file mode 100644 index c63b984..0000000 --- a/build.ps1 +++ /dev/null @@ -1,124 +0,0 @@ -# PowerShell Build Script for ModelContextProtocol Server -param( - [Parameter(Position=0)] - [string]$Target = "build", - [string]$Configuration = "Release", - [string]$Runtime = "win-x64" -) - -# Project configuration -$ProjectFile = "modelcontextprotocol.csproj" -$OutputDir = "bin" - -function Show-Help { - Write-Host "Available targets:" -ForegroundColor Green - Write-Host " build - Build the project in Release configuration" - Write-Host " build-debug - Build the project in Debug configuration" - Write-Host " restore - Restore NuGet packages" - Write-Host " clean - Clean build artifacts" - Write-Host " publish - Publish the application as self-contained executable" - Write-Host " run - Run the application" - Write-Host " run-debug - Run the application in debug mode" - Write-Host " test - Run tests" - Write-Host " rebuild - Clean, restore, and build" - Write-Host " help - Show this help message" - Write-Host "" - Write-Host "Usage examples:" -ForegroundColor Yellow - Write-Host " .\build.ps1" - Write-Host " .\build.ps1 build" - Write-Host " .\build.ps1 run" - Write-Host " .\build.ps1 publish" -} - -function Test-DotNet { - try { - $version = dotnet --version - Write-Host "Using .NET SDK version: $version" -ForegroundColor Green - return $true - } - catch { - Write-Error "Error: .NET SDK is not installed or not in PATH" - return $false - } -} - -function Invoke-Build { - Write-Host "Building ModelContextProtocol Server..." -ForegroundColor Cyan - dotnet build $ProjectFile --configuration $Configuration -} - -function Invoke-BuildDebug { - Write-Host "Building ModelContextProtocol Server (Debug)..." -ForegroundColor Cyan - dotnet build $ProjectFile --configuration Debug -} - -function Invoke-Restore { - Write-Host "Restoring NuGet packages..." -ForegroundColor Cyan - dotnet restore $ProjectFile -} - -function Invoke-Clean { - Write-Host "Cleaning build artifacts..." -ForegroundColor Cyan - dotnet clean $ProjectFile - - if (Test-Path $OutputDir) { - Remove-Item $OutputDir -Recurse -Force - Write-Host "Removed $OutputDir" -ForegroundColor Yellow - } - - $objDir = "obj" - if (Test-Path $objDir) { - Remove-Item $objDir -Recurse -Force - Write-Host "Removed $objDir" -ForegroundColor Yellow - } -} - -function Invoke-Publish { - Write-Host "Publishing ModelContextProtocol Server..." -ForegroundColor Cyan - dotnet publish $ProjectFile --configuration $Configuration --runtime $Runtime --self-contained true --single-file -} - -function Invoke-Run { - Write-Host "Running ModelContextProtocol Server..." -ForegroundColor Cyan - dotnet run --project $ProjectFile -} - -function Invoke-RunDebug { - Write-Host "Running ModelContextProtocol Server (Debug)..." -ForegroundColor Cyan - dotnet run --project $ProjectFile --configuration Debug -} - -function Invoke-Test { - Write-Host "Running tests..." -ForegroundColor Cyan - dotnet test $ProjectFile -} - -function Invoke-Rebuild { - Write-Host "Performing full rebuild..." -ForegroundColor Cyan - Invoke-Clean - Invoke-Restore - Invoke-Build -} - -# Main execution -if (-not (Test-DotNet)) { - exit 1 -} - -switch ($Target.ToLower()) { - "build" { Invoke-Build } - "build-debug" { Invoke-BuildDebug } - "restore" { Invoke-Restore } - "clean" { Invoke-Clean } - "publish" { Invoke-Publish } - "run" { Invoke-Run } - "run-debug" { Invoke-RunDebug } - "test" { Invoke-Test } - "rebuild" { Invoke-Rebuild } - "help" { Show-Help } - default { - Write-Host "Unknown target: $Target" -ForegroundColor Red - Show-Help - exit 1 - } -} \ No newline at end of file diff --git a/modelcontextprotocol.csproj b/modelcontextprotocol.csproj new file mode 100644 index 0000000..aad2871 --- /dev/null +++ b/modelcontextprotocol.csproj @@ -0,0 +1,41 @@ + + + + Exe + net9.0 + SandboxModelContextProtocol.Server + 13 + 1701;1702;1591; + + False + false + DEBUG + False + enable + + + true + true + win-x64 + + + + + bin\ + SandboxModelContextProtocol.Server + + + + + + + + + + + + + + + + \ No newline at end of file