diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index a0d2dfc0..97ee54bc 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,26 +1,155 @@ -# This workflow will build a .NET project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net +# This workflow builds, tests, and releases the Resgrid solution. +# - PRs on any branch: build + test +# - Merged PRs into master: build + test + Docker Hub publish + GitHub Release name: .NET on: pull_request: - branches: [ "master", "develop" ] + types: [opened, synchronize, reopened] + push: + branches: [ "master" ] -jobs: - build: +permissions: + contents: read + +env: + DOTNET_VERSION: '9.0.x' +jobs: + # ─────────────────────────────────────────────── + # Build & Test – runs on every PR and push to master + # ─────────────────────────────────────────────── + build-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Restore dependencies run: dotnet restore + - name: Build - run: dotnet build --no-restore + run: dotnet build --no-restore --configuration Release + - name: Test - run: dotnet test --no-build --verbosity normal + run: dotnet test --no-build --configuration Release --verbosity normal + + # ─────────────────────────────────────────────── + # Docker Build & Push – only on merged PRs to master + # Uses a matrix to build each project's Dockerfile + # ─────────────────────────────────────────────── + docker-build-and-push: + needs: build-and-test + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + - image: resgridllc/resgridwebcore + dockerfile: Web/Resgrid.Web/Dockerfile + - image: resgridllc/resgridwebservices + dockerfile: Web/Resgrid.Web.Services/Dockerfile + - image: resgridllc/resgridwebevents + dockerfile: Web/Resgrid.Web.Eventing/Dockerfile + - image: resgridllc/resgridwebmcp + dockerfile: Web/Resgrid.Web.Mcp/Dockerfile + - image: resgridllc/resgridworkersconsole + dockerfile: Workers/Resgrid.Workers.Console/Dockerfile + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract version + id: version + run: | + VERSION="4.${{ github.run_number }}.0" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ matrix.image }} + tags: | + type=raw,value=${{ steps.version.outputs.version }} + type=raw,value=latest + type=sha,prefix= + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + push: true + build-args: BUILD_VERSION=${{ steps.version.outputs.version }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ─────────────────────────────────────────────── + # GitHub Release – created after all Docker images are pushed + # Uses the body of the merged PR as release notes + # ─────────────────────────────────────────────── + github-release: + needs: docker-build-and-push + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Extract version + id: version + run: | + VERSION="4.${{ github.run_number }}.0" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Get merged PR info + id: pr-info + uses: actions/github-script@v7 + with: + script: | + const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.sha, + }); + const pr = prs.data.find(p => p.merged_at); + const fs = require('fs'); + if (pr) { + fs.writeFileSync('release_notes.md', pr.body || 'No release notes provided.'); + } else { + fs.writeFileSync('release_notes.md', 'No release notes provided.'); + } + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + name: Release v${{ steps.version.outputs.version }} + body_path: release_notes.md + draft: false + prerelease: false + make_latest: true + fail_on_unmatched_files: false diff --git a/Core/Resgrid.Services/VoiceService.cs b/Core/Resgrid.Services/VoiceService.cs index fff44ffe..1b717367 100644 --- a/Core/Resgrid.Services/VoiceService.cs +++ b/Core/Resgrid.Services/VoiceService.cs @@ -38,10 +38,14 @@ public VoiceService(IDepartmentVoiceRepository departmentVoiceRepository, IDepar public async Task CanDepartmentUseVoiceAsync(int departmentId) { var addonPlans = await _subscriptionsService.GetAllAddonPlansByTypeAsync(PlanAddonTypes.PTT); - var addonPayment = await _subscriptionsService.GetCurrentPaymentAddonsForDepartmentAsync(departmentId, addonPlans.Select(x => x.PlanAddonId).ToList()); - if (addonPayment != null && addonPayment.Count > 0) - return true; + if (addonPlans != null && addonPlans.Any()) + { + var addonPayment = await _subscriptionsService.GetCurrentPaymentAddonsForDepartmentAsync(departmentId, addonPlans.Select(x => x.PlanAddonId).ToList()); + + if (addonPayment != null && addonPayment.Count > 0) + return true; + } return false; } @@ -61,7 +65,7 @@ public async Task GetVoiceSettingsForDepartmentAsync(int depart var voice = await GetOrCreateDepartmentVoiceRecordAsync(department); var users = await _departmentsService.GetAllUsersForDepartmentAsync(departmentId, true, true); var userProfiles = await _userProfileService.GetAllProfilesForDepartmentAsync(departmentId, true); - + if (users != null && users.Any()) { foreach (var user in users) @@ -253,7 +257,7 @@ public async Task GetVoiceChannelByIdAsync(string voiceC } } } - + return await _departmentVoiceChannelRepository.SaveOrUpdateAsync(voiceChannel, cancellationToken, true); } diff --git a/Resgrid.sln b/Resgrid.sln index ec6b3c02..23d44aba 100644 --- a/Resgrid.sln +++ b/Resgrid.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.32112.339 @@ -78,6 +78,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Resgrid.Workers.Console", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Resgrid.Web.Eventing", "Web\Resgrid.Web.Eventing\Resgrid.Web.Eventing.csproj", "{907CA744-0D45-4928-AC26-5724BA6A38EF}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Resgrid.Web.Mcp", "Web\Resgrid.Web.Mcp\Resgrid.Web.Mcp.csproj", "{A8B9C1D2-E3F4-5678-9ABC-DEF012345678}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Resgrid.Providers.Voip", "Providers\Resgrid.Providers.Voip\Resgrid.Providers.Voip.csproj", "{FFCBA7D4-853A-4D25-935B-F242851752EE}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Resgrid.Repositories.NoSqlRepository", "Repositories\Resgrid.Repositories.NoSqlRepository\Resgrid.Repositories.NoSqlRepository.csproj", "{086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}" @@ -706,8 +708,32 @@ Global {907CA744-0D45-4928-AC26-5724BA6A38EF}.Release|x86.Build.0 = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|Any CPU.ActiveCfg = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|Any CPU.Build.0 = Release|Any CPU - {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|x86.ActiveCfg = Release|Any CPU - {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|x86.Build.0 = Release|Any CPU + {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|x86.ActiveCfg = Debug|Any CPU + {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|x86.Build.0 = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|Any CPU.ActiveCfg = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|Any CPU.Build.0 = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|x86.ActiveCfg = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|x86.Build.0 = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|Any CPU.Build.0 = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|x86.ActiveCfg = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|x86.Build.0 = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Debug|x86.ActiveCfg = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Debug|x86.Build.0 = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Docker|Any CPU.ActiveCfg = Docker|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Docker|Any CPU.Build.0 = Docker|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Docker|x86.ActiveCfg = Docker|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Docker|x86.Build.0 = Docker|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Release|Any CPU.Build.0 = Release|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Release|x86.ActiveCfg = Release|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Release|x86.Build.0 = Release|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Staging|Any CPU.ActiveCfg = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Staging|Any CPU.Build.0 = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Staging|x86.ActiveCfg = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Staging|x86.Build.0 = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Azure|Any CPU.Build.0 = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Azure|x86.ActiveCfg = Debug|Any CPU @@ -882,6 +908,7 @@ Global {C89A962F-75AC-4D0B-9B8A-B481F63810EC} = {DBB9862A-C008-4C3F-A9DB-320429E4A07F} {477AAB02-9403-44BE-B912-3DA98660F307} = {DBB9862A-C008-4C3F-A9DB-320429E4A07F} {907CA744-0D45-4928-AC26-5724BA6A38EF} = {53B024F9-E293-42F1-BA67-7F68C3F3C243} + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678} = {53B024F9-E293-42F1-BA67-7F68C3F3C243} {FFCBA7D4-853A-4D25-935B-F242851752EE} = {F06D475C-635C-4DE4-82BA-C49A90BA8FCD} {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E} = {206D5D48-99B0-4913-B1E2-4BA11D021740} {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F} = {D43D1D6B-66A9-4A57-9EA3-8DECC92FA583} diff --git a/Web/Resgrid.Web.Mcp/.gitignore b/Web/Resgrid.Web.Mcp/.gitignore new file mode 100644 index 00000000..66cb5190 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +*.user +*.suo +.vs/ + diff --git a/Web/Resgrid.Web.Mcp/ApiClient.cs b/Web/Resgrid.Web.Mcp/ApiClient.cs new file mode 100644 index 00000000..e9ee641a --- /dev/null +++ b/Web/Resgrid.Web.Mcp/ApiClient.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace Resgrid.Web.Mcp +{ + /// + /// Client for making authenticated requests to the Resgrid API + /// + public sealed class ApiClient : IApiClient + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public ApiClient(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async Task AuthenticateAsync( + string username, + string password, + CancellationToken cancellationToken = default) + { + try + { + var client = _httpClientFactory.CreateClient("ResgridApi"); + + var formContent = new FormUrlEncodedContent(new[] + { + new KeyValuePair("grant_type", "password"), + new KeyValuePair("username", username), + new KeyValuePair("password", password), + new KeyValuePair("scope", "openid profile email") + }); + + var response = await client.PostAsync("/api/v4/connect/token", formContent, cancellationToken); + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var tokenResponse = JsonConvert.DeserializeObject(content); + + if (tokenResponse is null) + { + _logger.LogError("Failed to deserialize token response. Response contained {ContentLength} characters but could not be parsed.", content.Length); + return new AuthenticationResult + { + IsSuccess = false, + ErrorMessage = "Invalid response format from authentication server" + }; + } + + return new AuthenticationResult + { + IsSuccess = true, + AccessToken = tokenResponse.AccessToken, + TokenType = tokenResponse.TokenType, + ExpiresIn = tokenResponse.ExpiresIn + }; + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogWarning("Authentication failed: {StatusCode} - {Error}", response.StatusCode, errorContent); + + return new AuthenticationResult + { + IsSuccess = false, + ErrorMessage = $"Authentication failed: {response.StatusCode}" + }; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during authentication"); + return new AuthenticationResult + { + IsSuccess = false, + ErrorMessage = "An error occurred during authentication" + }; + } + } + + public async Task GetAsync( + string endpoint, + string accessToken, + CancellationToken cancellationToken = default) + { + var client = CreateAuthenticatedClient(accessToken); + + try + { + var response = await client.GetAsync(endpoint, cancellationToken); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var result = JsonConvert.DeserializeObject(content); + + if (result is null) + { + _logger.LogError("Failed to deserialize response from {Endpoint}. StatusCode: {StatusCode}", endpoint, response.StatusCode); + throw new InvalidOperationException($"Failed to deserialize response from {endpoint}. StatusCode: {response.StatusCode}"); + } + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error making GET request to {Endpoint}", endpoint); + throw; + } + } + + public async Task PostAsync( + string endpoint, + TRequest request, + string accessToken, + CancellationToken cancellationToken = default) + { + var client = CreateAuthenticatedClient(accessToken); + + try + { + var json = JsonConvert.SerializeObject(request); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await client.PostAsync(endpoint, content, cancellationToken); + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + var result = JsonConvert.DeserializeObject(responseContent); + + if (result is null) + { + _logger.LogError("Failed to deserialize response from {Endpoint}. StatusCode: {StatusCode}", endpoint, response.StatusCode); + throw new InvalidOperationException($"Failed to deserialize response from {endpoint}. StatusCode: {response.StatusCode}"); + } + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error making POST request to {Endpoint}", endpoint); + throw; + } + } + + public async Task PutAsync( + string endpoint, + TRequest request, + string accessToken, + CancellationToken cancellationToken = default) + { + var client = CreateAuthenticatedClient(accessToken); + + try + { + var json = JsonConvert.SerializeObject(request); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await client.PutAsync(endpoint, content, cancellationToken); + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + var result = JsonConvert.DeserializeObject(responseContent); + + if (result is null) + { + _logger.LogError("Failed to deserialize response from {Endpoint}. StatusCode: {StatusCode}", endpoint, response.StatusCode); + throw new InvalidOperationException($"Failed to deserialize response from {endpoint}. StatusCode: {response.StatusCode}"); + } + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error making PUT request to {Endpoint}", endpoint); + throw; + } + } + + public async Task DeleteAsync( + string endpoint, + string accessToken, + CancellationToken cancellationToken = default) + { + var client = CreateAuthenticatedClient(accessToken); + + try + { + var response = await client.DeleteAsync(endpoint, cancellationToken); + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error making DELETE request to {Endpoint}", endpoint); + throw; + } + } + + private HttpClient CreateAuthenticatedClient(string accessToken) + { + var client = _httpClientFactory.CreateClient("ResgridApi"); + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + return client; + } + + private sealed class TokenResponse + { + [JsonProperty("access_token")] + public string AccessToken { get; set; } + + [JsonProperty("token_type")] + public string TokenType { get; set; } + + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } + } + } +} diff --git a/Web/Resgrid.Web.Mcp/Dockerfile b/Web/Resgrid.Web.Mcp/Dockerfile new file mode 100644 index 00000000..1f843531 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Dockerfile @@ -0,0 +1,46 @@ +#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. + +ARG BUILD_VERSION=3.5.0 + +FROM mcr.microsoft.com/dotnet/aspnet:9.0.3-noble-amd64 AS base +ARG BUILD_VERSION +WORKDIR /app +EXPOSE 80 + +FROM mcr.microsoft.com/dotnet/sdk:9.0.202-noble-amd64 AS build +ARG BUILD_VERSION +WORKDIR /src +COPY ["Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj", "Web/Resgrid.Web.Mcp/"] +COPY ["Providers/Resgrid.Providers.Bus.Rabbit/Resgrid.Providers.Bus.Rabbit.csproj", "Providers/Resgrid.Providers.Bus.Rabbit/"] +COPY ["Core/Resgrid.Framework/Resgrid.Framework.csproj", "Core/Resgrid.Framework/"] +COPY ["Core/Resgrid.Config/Resgrid.Config.csproj", "Core/Resgrid.Config/"] +COPY ["Core/Resgrid.Model/Resgrid.Model.csproj", "Core/Resgrid.Model/"] +COPY ["Providers/Resgrid.Providers.AddressVerification/Resgrid.Providers.AddressVerification.csproj", "Providers/Resgrid.Providers.AddressVerification/"] +COPY ["Core/Resgrid.Services/Resgrid.Services.csproj", "Core/Resgrid.Services/"] +COPY ["Providers/Resgrid.Providers.Bus/Resgrid.Providers.Bus.csproj", "Providers/Resgrid.Providers.Bus/"] +COPY ["Providers/Resgrid.Providers.Cache/Resgrid.Providers.Cache.csproj", "Providers/Resgrid.Providers.Cache/"] +COPY ["Providers/Resgrid.Providers.Geo/Resgrid.Providers.Geo.csproj", "Providers/Resgrid.Providers.Geo/"] +COPY ["Repositories/Resgrid.Repositories.DataRepository/Resgrid.Repositories.DataRepository.csproj", "Repositories/Resgrid.Repositories.DataRepository/"] +COPY ["Providers/Resgrid.Providers.Number/Resgrid.Providers.Number.csproj", "Providers/Resgrid.Providers.Number/"] +COPY ["Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj", "Providers/Resgrid.Providers.Email/"] +COPY ["Providers/Resgrid.Providers.Marketing/Resgrid.Providers.Marketing.csproj", "Providers/Resgrid.Providers.Marketing/"] +COPY ["Providers/Resgrid.Providers.Pdf/Resgrid.Providers.Pdf.csproj", "Providers/Resgrid.Providers.Pdf/"] +COPY ["Providers/Resgrid.Providers.Claims/Resgrid.Providers.Claims.csproj", "Providers/Resgrid.Providers.Claims/"] +COPY ["Providers/Resgrid.Providers.Migrations/Resgrid.Providers.Migrations.csproj", "Providers/Resgrid.Providers.Migrations/"] +COPY ["Providers/Resgrid.Providers.Voip/Resgrid.Providers.Voip.csproj", "Providers/Resgrid.Providers.Voip/"] +RUN dotnet restore "Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj" +COPY . . +WORKDIR "/src/Web/Resgrid.Web.Mcp" + +FROM build AS publish +ARG BUILD_VERSION +RUN dotnet publish "Resgrid.Web.Mcp.csproj" -c Release -o /app/publish -p:Version=${BUILD_VERSION} + +FROM base AS final +## Add the wait script to the image +ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.9.0/wait wait +RUN chmod +x wait + +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["sh", "-c", "./wait && dotnet Resgrid.Web.Mcp.dll"] diff --git a/Web/Resgrid.Web.Mcp/IApiClient.cs b/Web/Resgrid.Web.Mcp/IApiClient.cs new file mode 100644 index 00000000..0c423087 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/IApiClient.cs @@ -0,0 +1,49 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Web.Mcp +{ + /// + /// Interface for API client that handles authenticated requests to Resgrid API + /// + public interface IApiClient + { + /// + /// Authenticates a user and returns an access token + /// + Task AuthenticateAsync(string username, string password, CancellationToken cancellationToken = default); + + /// + /// Makes an authenticated GET request to the API + /// + Task GetAsync(string endpoint, string accessToken, CancellationToken cancellationToken = default); + + /// + /// Makes an authenticated POST request to the API + /// + Task PostAsync(string endpoint, TRequest request, string accessToken, CancellationToken cancellationToken = default); + + /// + /// Makes an authenticated PUT request to the API + /// + Task PutAsync(string endpoint, TRequest request, string accessToken, CancellationToken cancellationToken = default); + + /// + /// Makes an authenticated DELETE request to the API + /// + Task DeleteAsync(string endpoint, string accessToken, CancellationToken cancellationToken = default); + } + + /// + /// Result of authentication attempt + /// + public sealed record AuthenticationResult + { + public bool IsSuccess { get; init; } + public string AccessToken { get; init; } + public string TokenType { get; init; } + public int ExpiresIn { get; init; } + public string ErrorMessage { get; init; } + } +} + diff --git a/Web/Resgrid.Web.Mcp/Infrastructure/AuditLogger.Test.md b/Web/Resgrid.Web.Mcp/Infrastructure/AuditLogger.Test.md new file mode 100644 index 00000000..9bfdbec7 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Infrastructure/AuditLogger.Test.md @@ -0,0 +1,92 @@ +# AuditLogger Sanitization Test + +## Overview +The AuditLogger has been updated to sanitize sensitive data before logging to prevent exposure of credentials and tokens. + +## What Was Changed + +### 1. Added Sensitive Field Detection +A HashSet of sensitive field names is maintained: +- password +- accessToken +- token +- secret +- apiKey +- authorization +- bearer +- credential +- privateKey + +### 2. Implemented Sanitization Logic +The `SanitizeSensitiveData` method: +- Converts objects to JSON for manipulation +- Recursively scans all properties +- Replaces sensitive field values with "***REDACTED***" +- Returns the sanitized object + +### 3. Updated LogToolCallAsync +The method now calls `SanitizeSensitiveData(arguments)` before storing in audit entry details. + +### 4. Protected AccessToken in AuditEntry +Added `[JsonIgnore]` attribute to the `AccessToken` property to prevent it from being serialized in logs. + +## Example Behavior + +### Before Sanitization: +```json +{ + "toolName": "authenticate", + "arguments": { + "username": "user@example.com", + "password": "MySecretPassword123" + } +} +``` + +### After Sanitization: +```json +{ + "toolName": "authenticate", + "arguments": { + "username": "user@example.com", + "password": "***REDACTED***" + } +} +``` + +### Complex Nested Example: +```json +{ + "toolName": "send_message", + "arguments": { + "accessToken": "***REDACTED***", + "message": { + "subject": "Hello", + "body": "This is a test", + "credentials": "***REDACTED***" + }, + "recipients": ["user1", "user2"] + } +} +``` + +## Security Benefits + +1. **Prevents Password Leakage**: User passwords in authentication logs are redacted +2. **Protects Access Tokens**: API tokens in tool calls are not exposed +3. **Handles Nested Objects**: Recursively sanitizes nested structures +4. **Fail-Safe**: If sanitization fails, returns a safe placeholder +5. **Case-Insensitive**: Works regardless of field name casing + +## Testing + +To verify the sanitization is working: + +1. Call the authenticate tool with a username/password +2. Check the audit logs +3. Verify password is shown as "***REDACTED***" + +4. Call any tool with an accessToken +5. Check the audit logs +6. Verify accessToken is shown as "***REDACTED***" + diff --git a/Web/Resgrid.Web.Mcp/Infrastructure/AuditLogger.cs b/Web/Resgrid.Web.Mcp/Infrastructure/AuditLogger.cs new file mode 100644 index 00000000..a1017c1d --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Infrastructure/AuditLogger.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Web.Mcp.Infrastructure +{ + /// + /// Audit logging service for tracking MCP operations + /// + public interface IAuditLogger + { + Task LogOperationAsync(AuditEntry entry); + Task LogAuthenticationAsync(string userId, bool success, string ipAddress); + Task LogToolCallAsync(string userId, string toolName, object arguments, bool success, string error = null); + } + + public sealed class AuditLogger : IAuditLogger + { + private readonly ILogger _logger; + private readonly IApiClient _apiClient; + + // Sensitive field names that should be redacted from audit logs + private static readonly HashSet SensitiveFields = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "password", + "accessToken", + "token", + "secret", + "apiKey", + "authorization", + "bearer", + "credential", + "privateKey" + }; + + public AuditLogger(ILogger logger, IApiClient apiClient) + { + _logger = logger; + _apiClient = apiClient; + } + + public async Task LogOperationAsync(AuditEntry entry) + { + try + { + // Log locally + _logger.LogInformation( + "AUDIT: User={UserId}, Operation={Operation}, Success={Success}, Duration={Duration}ms", + entry.UserId, + entry.Operation, + entry.Success, + entry.DurationMs + ); + + // Could also send to external audit service or database + // await _apiClient.PostAsync("/api/v4/Audit/LogEntry", entry, entry.AccessToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error writing audit log"); + } + } + + public async Task LogAuthenticationAsync(string userId, bool success, string ipAddress) + { + var entry = new AuditEntry + { + UserId = userId, + Operation = "Authentication", + Success = success, + Timestamp = DateTime.UtcNow, + IpAddress = ipAddress, + Details = new { userId, success } + }; + + await LogOperationAsync(entry); + } + + public async Task LogToolCallAsync(string userId, string toolName, object arguments, bool success, string error = null) + { + var entry = new AuditEntry + { + UserId = userId, + Operation = $"ToolCall:{toolName}", + Success = success, + Timestamp = DateTime.UtcNow, + Details = new + { + toolName, + arguments = SanitizeSensitiveData(arguments), + error + } + }; + + await LogOperationAsync(entry); + } + + /// + /// Sanitizes sensitive data from objects before logging + /// + /// The data to sanitize + /// A sanitized copy of the data with sensitive fields redacted + internal static object SanitizeSensitiveData(object data) + { + if (data == null) + return null; + + try + { + // Convert to JToken for easier manipulation + var json = JsonConvert.SerializeObject(data); + var jToken = JToken.Parse(json); + + AuditLogger.SanitizeJToken(jToken); + + // Convert back to object + return JsonConvert.DeserializeObject(jToken.ToString()); + } + catch (JsonException) + { + // If JSON serialization/deserialization fails, return a safe placeholder + // Note: Logger is not available in static context, but we return a safe error object + return new { sanitized = true, error = "Unable to sanitize data" }; + } + } + + /// + /// Recursively sanitizes sensitive fields in a JToken + /// + private static void SanitizeJToken(JToken token) + { + if (token == null) + return; + + switch (token.Type) + { + case JTokenType.Object: + var obj = (JObject)token; + var properties = obj.Properties().ToList(); + + foreach (var prop in properties) + { + if (SensitiveFields.Contains(prop.Name)) + { + // Redact sensitive fields + prop.Value = JValue.CreateString("***REDACTED***"); + } + else + { + // Recursively sanitize nested objects + SanitizeJToken(prop.Value); + } + } + break; + + case JTokenType.Array: + foreach (var item in (JArray)token) + { + SanitizeJToken(item); + } + break; + } + } + } + + public sealed class AuditEntry + { + public string UserId { get; set; } + public string Operation { get; set; } + public bool Success { get; set; } + public DateTime Timestamp { get; set; } + public string IpAddress { get; set; } + public object Details { get; set; } + public long DurationMs { get; set; } + + /// + /// Access token for API calls - excluded from serialization for security + /// + [JsonIgnore] + public string AccessToken { get; set; } + + public override string ToString() + { + // Explicitly create object without AccessToken to prevent token leakage in logs + // Sanitize Details to redact sensitive fields like passwords and tokens + var safeObject = new + { + UserId, + Operation, + Success, + Timestamp, + IpAddress, + Details = AuditLogger.SanitizeSensitiveData(Details), + DurationMs + }; + + return JsonConvert.SerializeObject(safeObject, Formatting.None); + } + } +} + diff --git a/Web/Resgrid.Web.Mcp/Infrastructure/RateLimiter.cs b/Web/Resgrid.Web.Mcp/Infrastructure/RateLimiter.cs new file mode 100644 index 00000000..0bd67d14 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Infrastructure/RateLimiter.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Resgrid.Web.Mcp.Infrastructure +{ + /// + /// Rate limiting service for MCP server + /// + public interface IRateLimiter + { + Task IsAllowedAsync(string clientId, string operation); + void Reset(string clientId); + } + + public sealed class RateLimiter : IRateLimiter, IDisposable + { + private readonly ILogger _logger; + private readonly ConcurrentDictionary _counters; + private readonly Timer _cleanupTimer; + private bool _disposed; + + // Rate limits: 100 requests per minute per client + private const int MaxRequestsPerMinute = 100; + private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); + + public RateLimiter(ILogger logger) + { + _logger = logger; + _counters = new ConcurrentDictionary(); + _cleanupTimer = new Timer(CleanupExpired, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); + } + + public Task IsAllowedAsync(string clientId, string operation) + { + var key = $"{clientId}:{operation}"; + var counter = _counters.GetOrAdd(key, _ => new RequestCounter()); + + var now = DateTime.UtcNow; + var allowed = counter.TryAddIfUnderLimit(now, Window, MaxRequestsPerMinute); + + if (!allowed) + { + _logger.LogWarning("Rate limit exceeded for client {ClientId}, operation {Operation}", clientId, operation); + } + + return Task.FromResult(allowed); + } + + public void Reset(string clientId) + { + var prefix = clientId + ":"; + var keysToRemove = new System.Collections.Generic.List(); + + foreach (var key in _counters.Keys) + { + if (key.StartsWith(prefix)) + { + keysToRemove.Add(key); + } + } + + foreach (var key in keysToRemove) + { + _counters.TryRemove(key, out _); + } + + _logger.LogDebug("Reset rate limit for client: {ClientId}, removed {Count} operation(s)", clientId, keysToRemove.Count); + } + + private void CleanupExpired(object state) + { + var now = DateTime.UtcNow; + var keysToRemove = new System.Collections.Generic.List(); + + foreach (var kvp in _counters) + { + // First clean up old requests within the counter + kvp.Value.CleanupOldRequests(now, Window); + + // Then check if the counter is empty (no recent requests) + if (kvp.Value.Count == 0) + { + keysToRemove.Add(kvp.Key); + } + } + + foreach (var key in keysToRemove) + { + _counters.TryRemove(key, out _); + } + + if (keysToRemove.Count > 0) + { + _logger.LogDebug("Cleaned up {Count} expired rate limit entries", keysToRemove.Count); + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _cleanupTimer?.Dispose(); + _disposed = true; + } + + private sealed class RequestCounter + { + private readonly object _lock = new object(); + private System.Collections.Generic.Queue _requests = new System.Collections.Generic.Queue(); + + public int Count + { + get + { + lock (_lock) + { + return _requests.Count; + } + } + } + + /// + /// Atomically cleans up old requests, checks if under the limit, and adds the request if allowed. + /// This method prevents TOCTOU race conditions by performing all operations under a single lock. + /// + /// Current timestamp for the request + /// Time window for rate limiting + /// Maximum number of requests allowed within the window + /// True if the request was added (under limit), false if rejected (over limit) + public bool TryAddIfUnderLimit(DateTime timestamp, TimeSpan window, int maxRequests) + { + lock (_lock) + { + // Clean up old requests outside the window + var cutoff = timestamp.Subtract(window); + while (_requests.Count > 0 && _requests.Peek() < cutoff) + { + _requests.Dequeue(); + } + + // Check if under the limit + if (_requests.Count >= maxRequests) + { + return false; + } + + // Add the request + _requests.Enqueue(timestamp); + return true; + } + } + + public void CleanupOldRequests(DateTime now, TimeSpan window) + { + lock (_lock) + { + var cutoff = now.Subtract(window); + while (_requests.Count > 0 && _requests.Peek() < cutoff) + { + _requests.Dequeue(); + } + } + } + } + } +} + diff --git a/Web/Resgrid.Web.Mcp/Infrastructure/ResponseCache.cs b/Web/Resgrid.Web.Mcp/Infrastructure/ResponseCache.cs new file mode 100644 index 00000000..b5b2945e --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Infrastructure/ResponseCache.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace Resgrid.Web.Mcp.Infrastructure +{ + /// + /// Response caching service for MCP server + /// + public interface IResponseCache + { + Task GetOrCreateAsync(string key, Func> factory, TimeSpan? expiration = null); + void Remove(string key); + void Clear(); + } + + public sealed class ResponseCache : IResponseCache + { + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private static readonly TimeSpan DefaultExpiration = TimeSpan.FromMinutes(5); + + public ResponseCache(IMemoryCache cache, ILogger logger) + { + _cache = cache; + _logger = logger; + } + + public async Task GetOrCreateAsync(string key, Func> factory, TimeSpan? expiration = null) + { + if (_cache.TryGetValue(key, out T cachedValue)) + { + _logger.LogDebug("Cache hit for key: {Key}", key); + return cachedValue; + } + + _logger.LogDebug("Cache miss for key: {Key}", key); + var value = await factory(); + + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = expiration ?? DefaultExpiration + }; + + _cache.Set(key, value, cacheOptions); + return value; + } + + public void Remove(string key) + { + _cache.Remove(key); + _logger.LogDebug("Removed cache key: {Key}", key); + } + + public void Clear() + { + if (_cache is MemoryCache memoryCache) + { + memoryCache.Compact(1.0); + _logger.LogInformation("Cache cleared"); + } + } + } +} + diff --git a/Web/Resgrid.Web.Mcp/Infrastructure/TokenRefreshService.cs b/Web/Resgrid.Web.Mcp/Infrastructure/TokenRefreshService.cs new file mode 100644 index 00000000..ae3511f2 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Infrastructure/TokenRefreshService.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Resgrid.Web.Mcp.Infrastructure +{ + /// + /// Token refresh service for managing OAuth2 token lifecycle + /// + public interface ITokenRefreshService + { + Task GetValidTokenAsync(string userId, string refreshToken); + void CacheToken(string userId, string accessToken, string refreshToken, int expiresIn); + void InvalidateToken(string userId); + } + + public sealed class TokenRefreshService : ITokenRefreshService + { + private readonly IApiClient _apiClient; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _tokenCache; + private readonly Timer _cleanupTimer; + + public TokenRefreshService(IApiClient apiClient, ILogger logger) + { + _apiClient = apiClient; + _logger = logger; + _tokenCache = new ConcurrentDictionary(); + _cleanupTimer = new Timer(CleanupExpired, null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); + } + + public async Task GetValidTokenAsync(string userId, string refreshToken) + { + if (_tokenCache.TryGetValue(userId, out var cached)) + { + if (cached.ExpiresAt > DateTime.UtcNow.AddMinutes(5)) + { + _logger.LogDebug("Using cached token for user {UserId}", userId); + return cached.AccessToken; + } + + _logger.LogInformation("Token expired for user {UserId}, refreshing...", userId); + } + + try + { + // Call refresh token endpoint + var result = await RefreshTokenAsync(refreshToken); + + if (result.IsSuccess) + { + CacheToken(userId, result.AccessToken, result.RefreshToken, result.ExpiresIn); + return result.AccessToken; + } + + _logger.LogError("Failed to refresh token for user {UserId}", userId); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing token for user {UserId}", userId); + return null; + } + } + + public void CacheToken(string userId, string accessToken, string refreshToken, int expiresIn) + { + var cache = new TokenCache + { + AccessToken = accessToken, + RefreshToken = refreshToken, + ExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn) + }; + + _tokenCache.AddOrUpdate(userId, cache, (_, __) => cache); + _logger.LogDebug("Cached token for user {UserId}, expires at {ExpiresAt}", userId, cache.ExpiresAt); + } + + public void InvalidateToken(string userId) + { + _tokenCache.TryRemove(userId, out _); + _logger.LogDebug("Invalidated token for user {UserId}", userId); + } + + private async Task RefreshTokenAsync(string refreshToken) + { + try + { + // This would call the actual refresh token endpoint + // For now, returning a placeholder + // In real implementation, call: POST /api/v4/connect/token with grant_type=refresh_token + + _logger.LogWarning("Token refresh not fully implemented - requires refresh_token grant type support"); + + return new RefreshTokenResult + { + IsSuccess = false + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during token refresh"); + return new RefreshTokenResult { IsSuccess = false }; + } + } + + private void CleanupExpired(object state) + { + var keysToRemove = new System.Collections.Generic.List(); + var now = DateTime.UtcNow; + + foreach (var kvp in _tokenCache) + { + if (kvp.Value.ExpiresAt < now) + { + keysToRemove.Add(kvp.Key); + } + } + + foreach (var key in keysToRemove) + { + _tokenCache.TryRemove(key, out _); + } + + if (keysToRemove.Count > 0) + { + _logger.LogDebug("Cleaned up {Count} expired tokens", keysToRemove.Count); + } + } + + private sealed class TokenCache + { + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public DateTime ExpiresAt { get; set; } + } + + private sealed class RefreshTokenResult + { + public bool IsSuccess { get; set; } + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public int ExpiresIn { get; set; } + } + } +} + diff --git a/Web/Resgrid.Web.Mcp/McpServerHost.cs b/Web/Resgrid.Web.Mcp/McpServerHost.cs new file mode 100644 index 00000000..aea286a9 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/McpServerHost.cs @@ -0,0 +1,133 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace Resgrid.Web.Mcp +{ + /// + /// Hosted service that runs the MCP server + /// + public sealed class McpServerHost : IHostedService, IDisposable + { + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly McpToolRegistry _toolRegistry; + private readonly IHostApplicationLifetime _applicationLifetime; + private McpServer _mcpServer; + private Task _executingTask; + private CancellationTokenSource _stoppingCts; + private bool _disposed; + + public McpServerHost( + ILogger logger, + IConfiguration configuration, + McpToolRegistry toolRegistry, + IHostApplicationLifetime applicationLifetime) + { + _logger = logger; + _configuration = configuration; + _toolRegistry = toolRegistry; + _applicationLifetime = applicationLifetime; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting Resgrid MCP Server..."); + + _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + try + { + var serverName = _configuration["McpServer:ServerName"] ?? "Resgrid CAD System"; + var serverVersion = _configuration["McpServer:ServerVersion"] ?? "1.0.0"; + + // Create MCP server with server information + _mcpServer = new McpServer(serverName, serverVersion, _logger); + + // Register all tools from the registry + _toolRegistry.RegisterTools(_mcpServer); + + _logger.LogInformation("MCP Server initialized with {ToolCount} tools", _toolRegistry.GetToolCount()); + + // Start the server execution + _executingTask = ExecuteAsync(_stoppingCts.Token); + + if (_executingTask.IsCompleted) + { + return _executingTask; + } + + _logger.LogInformation("Resgrid MCP Server started successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start MCP Server"); + throw; + } + + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Stopping Resgrid MCP Server..."); + + if (_executingTask == null) + { + return; + } + + try + { + _stoppingCts?.Cancel(); + } + finally + { + await Task.WhenAny(_executingTask, Task.Delay(Timeout.Infinite, cancellationToken)); + + _stoppingCts?.Dispose(); + _stoppingCts = null; + } + + _logger.LogInformation("Resgrid MCP Server stopped"); + } + + private async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + _logger.LogInformation("MCP Server listening on stdio transport..."); + + // Run the server - this will handle stdio communication + await _mcpServer.RunAsync(stoppingToken); + } + catch (OperationCanceledException) + { + _logger.LogInformation("MCP Server execution was cancelled"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error in MCP Server execution"); + _applicationLifetime.StopApplication(); + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _stoppingCts?.Dispose(); + _disposed = true; + } + } +} + + + diff --git a/Web/Resgrid.Web.Mcp/McpToolRegistry.cs b/Web/Resgrid.Web.Mcp/McpToolRegistry.cs new file mode 100644 index 00000000..21c65d2e --- /dev/null +++ b/Web/Resgrid.Web.Mcp/McpToolRegistry.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using Resgrid.Web.Mcp.Tools; + +namespace Resgrid.Web.Mcp +{ + /// + /// Registry that manages and registers all MCP tools with the server + /// + public sealed class McpToolRegistry + { + private readonly ILogger _logger; + private readonly AuthenticationToolProvider _authTools; + private readonly CallsToolProvider _callsTools; + private readonly DispatchToolProvider _dispatchTools; + private readonly PersonnelToolProvider _personnelTools; + private readonly UnitsToolProvider _unitsTools; + private readonly MessagesToolProvider _messagesTools; + private readonly CalendarToolProvider _calendarTools; + private readonly ShiftsToolProvider _shiftsTools; + private readonly InventoryToolProvider _inventoryTools; + private readonly ReportsToolProvider _reportsTools; + private readonly List _registeredTools; + + public McpToolRegistry( + ILogger logger, + AuthenticationToolProvider authTools, + CallsToolProvider callsTools, + DispatchToolProvider dispatchTools, + PersonnelToolProvider personnelTools, + UnitsToolProvider unitsTools, + MessagesToolProvider messagesTools, + CalendarToolProvider calendarTools, + ShiftsToolProvider shiftsTools, + InventoryToolProvider inventoryTools, + ReportsToolProvider reportsTools) + { + _logger = logger; + _authTools = authTools; + _callsTools = callsTools; + _dispatchTools = dispatchTools; + _personnelTools = personnelTools; + _unitsTools = unitsTools; + _messagesTools = messagesTools; + _calendarTools = calendarTools; + _shiftsTools = shiftsTools; + _inventoryTools = inventoryTools; + _reportsTools = reportsTools; + _registeredTools = new List(); + } + + /// + /// Registers all tools with the MCP server + /// + public void RegisterTools(McpServer server) + { + _logger.LogInformation("Registering MCP tools..."); + + // Register Authentication tools + _authTools.RegisterTools(server); + _registeredTools.AddRange(_authTools.GetToolNames()); + + // Register Calls tools + _callsTools.RegisterTools(server); + _registeredTools.AddRange(_callsTools.GetToolNames()); + + // Register Dispatch tools + _dispatchTools.RegisterTools(server); + _registeredTools.AddRange(_dispatchTools.GetToolNames()); + + // Register Personnel tools + _personnelTools.RegisterTools(server); + _registeredTools.AddRange(_personnelTools.GetToolNames()); + + // Register Units tools + _unitsTools.RegisterTools(server); + _registeredTools.AddRange(_unitsTools.GetToolNames()); + + // Register Messages tools + _messagesTools.RegisterTools(server); + _registeredTools.AddRange(_messagesTools.GetToolNames()); + + // Register Calendar tools + _calendarTools.RegisterTools(server); + _registeredTools.AddRange(_calendarTools.GetToolNames()); + + // Register Shifts tools + _shiftsTools.RegisterTools(server); + _registeredTools.AddRange(_shiftsTools.GetToolNames()); + + // Register Inventory tools + _inventoryTools.RegisterTools(server); + _registeredTools.AddRange(_inventoryTools.GetToolNames()); + + // Register Reports tools + _reportsTools.RegisterTools(server); + _registeredTools.AddRange(_reportsTools.GetToolNames()); + + _logger.LogInformation("Registered {Count} MCP tools across {ProviderCount} providers", + _registeredTools.Count, 10); + } + + /// + /// Gets the count of registered tools + /// + public int GetToolCount() => _registeredTools.Count; + + /// + /// Gets the names of all registered tools + /// + public IReadOnlyList GetRegisteredTools() => _registeredTools.AsReadOnly(); + } +} + + + + + + + diff --git a/Web/Resgrid.Web.Mcp/ModelContextProtocol/McpServer.cs b/Web/Resgrid.Web.Mcp/ModelContextProtocol/McpServer.cs new file mode 100644 index 00000000..3a882574 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/ModelContextProtocol/McpServer.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace ModelContextProtocol.Server +{ + /// + /// Simple MCP Server implementation based on the Model Context Protocol specification + /// + public sealed class McpServer + { + private readonly string _serverName; + private readonly string _serverVersion; + private readonly Dictionary _tools; + private readonly ILogger _logger; + + public McpServer(string serverName, string serverVersion, ILogger logger = null) + { + _serverName = serverName; + _serverVersion = serverVersion; + _tools = new Dictionary(); + _logger = logger; + } + + public void AddTool(string name, string description, Dictionary inputSchema, Func> handler) + { + _tools[name] = new ToolDefinition + { + Name = name, + Description = description, + InputSchema = inputSchema, + Handler = handler + }; + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + _logger?.LogInformation("MCP Server starting stdio transport"); + + try + { + while (!cancellationToken.IsCancellationRequested) + { + // Start ReadLineAsync task + var readLineTask = Console.In.ReadLineAsync(); + + // Start cancellation task + var cancellationTask = Task.Delay(Timeout.Infinite, cancellationToken); + + // Race the two tasks + var completedTask = await Task.WhenAny(readLineTask, cancellationTask); + + // If cancellation won, exit the loop + if (completedTask == cancellationTask) + { + _logger?.LogInformation("MCP Server cancellation requested, exiting loop"); + break; + } + + // Otherwise, get the result from ReadLineAsync + var line = await readLineTask; + if (line == null) + break; + + if (string.IsNullOrWhiteSpace(line)) + continue; + + try + { + var request = JsonSerializer.Deserialize(line); + var response = await HandleRequestAsync(request, cancellationToken); + + var responseJson = JsonSerializer.Serialize(response); + await Console.Out.WriteLineAsync(responseJson); + await Console.Out.FlushAsync(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error processing request"); + var errorResponse = new JsonRpcResponse + { + Jsonrpc = "2.0", + Id = null, + Error = new JsonRpcError + { + Code = -32603, + Message = "Internal error", + Data = null + } + }; + var errorJson = JsonSerializer.Serialize(errorResponse); + await Console.Out.WriteLineAsync(errorJson); + await Console.Out.FlushAsync(); + } + } + } + catch (OperationCanceledException) + { + _logger?.LogInformation("MCP Server cancelled"); + } + } + + private async Task HandleRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken) + { + var response = new JsonRpcResponse + { + Jsonrpc = "2.0", + Id = request.Id + }; + + try + { + switch (request.Method) + { + case "initialize": + response.Result = new + { + protocolVersion = "2024-11-05", + capabilities = new + { + tools = new { } + }, + serverInfo = new + { + name = _serverName, + version = _serverVersion + } + }; + break; + + case "tools/list": + var toolsList = new List(); + foreach (var tool in _tools.Values) + { + toolsList.Add(new + { + name = tool.Name, + description = tool.Description, + inputSchema = tool.InputSchema + }); + } + response.Result = new { tools = toolsList }; + break; + + case "tools/call": + var toolCallParams = JsonSerializer.Deserialize( + JsonSerializer.Serialize(request.Params)); + + if (!_tools.TryGetValue(toolCallParams.Name, out var toolDef)) + { + response.Error = new JsonRpcError + { + Code = -32602, + Message = $"Tool not found: {toolCallParams.Name}", + Data = null + }; + return response; + } + + var result = await toolDef.Handler(toolCallParams.Arguments); + response.Result = new + { + content = new[] + { + new + { + type = "text", + text = JsonSerializer.Serialize(result) + } + } + }; + break; + + case "ping": + response.Result = new { }; + break; + + default: + response.Error = new JsonRpcError + { + Code = -32601, + Message = "Method not found", + Data = request.Method + }; + break; + } + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error handling method {Method}", request.Method); + response.Error = new JsonRpcError + { + Code = -32603, + Message = "Internal error", + Data = null + }; + } + + return response; + } + + private sealed class ToolDefinition + { + public string Name { get; set; } + public string Description { get; set; } + public Dictionary InputSchema { get; set; } + public Func> Handler { get; set; } + } + + private sealed class JsonRpcRequest + { + [JsonPropertyName("jsonrpc")] + public string Jsonrpc { get; set; } + + [JsonPropertyName("id")] + public object Id { get; set; } + + [JsonPropertyName("method")] + public string Method { get; set; } + + [JsonPropertyName("params")] + public object Params { get; set; } + } + + private sealed class JsonRpcResponse + { + [JsonPropertyName("jsonrpc")] + public string Jsonrpc { get; set; } + + [JsonPropertyName("id")] + public object Id { get; set; } + + [JsonPropertyName("result")] + public object Result { get; set; } + + [JsonPropertyName("error")] + public JsonRpcError Error { get; set; } + } + + private sealed class JsonRpcError + { + [JsonPropertyName("code")] + public int Code { get; set; } + + [JsonPropertyName("message")] + public string Message { get; set; } + + [JsonPropertyName("data")] + public object Data { get; set; } + } + + private sealed class ToolCallParams + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("arguments")] + public object Arguments { get; set; } + } + } +} diff --git a/Web/Resgrid.Web.Mcp/Program.cs b/Web/Resgrid.Web.Mcp/Program.cs new file mode 100644 index 00000000..86e92b3a --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Program.cs @@ -0,0 +1,124 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Autofac.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Resgrid.Config; + +namespace Resgrid.Web.Mcp +{ + public static class Program + { + public static async Task Main(string[] args) + { + try + { + var host = CreateHostBuilder(args).Build(); + await host.RunAsync(); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Fatal error: {ex}"); + return 1; + } + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .UseServiceProviderFactory(new AutofacServiceProviderFactory()) + .UseContentRoot(Directory.GetCurrentDirectory()) + .ConfigureAppConfiguration((hostingContext, config) => + { + config.SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables() + .AddCommandLine(args); + }) + .ConfigureLogging((hostingContext, logging) => + { + logging.ClearProviders(); + logging.AddConsole(); + logging.SetMinimumLevel(LogLevel.Information); + }) + .ConfigureServices((hostContext, services) => + { + var configuration = hostContext.Configuration; + + // Load Resgrid configuration + bool configResult = ConfigProcessor.LoadAndProcessConfig(configuration["AppOptions:ConfigPath"]); + if (!configResult) + { + throw new InvalidOperationException( + $"Failed to load configuration from path: {configuration["AppOptions:ConfigPath"] ?? "default path"}. " + + "Ensure the configuration file exists and is valid."); + } + + bool envConfigResult = ConfigProcessor.LoadAndProcessEnvVariables(configuration.AsEnumerable()); + if (!envConfigResult) + { + Console.WriteLine("Warning: No environment variables were loaded. This may be expected if not using environment-based configuration."); + } + + if (!string.IsNullOrWhiteSpace(ExternalErrorConfig.ExternalErrorServiceUrlForApi)) + { + Framework.Logging.Initialize(ExternalErrorConfig.ExternalErrorServiceUrlForApi); + } + + // Register MCP server + services.AddHostedService(); + + // Register infrastructure services + services.AddMemoryCache(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Validate required API configuration + var apiBaseUrl = configuration["McpServer:ApiBaseUrl"]; + if (string.IsNullOrWhiteSpace(apiBaseUrl)) + { + throw new InvalidOperationException( + "McpServer:ApiBaseUrl is required but not configured. " + + "Configure this setting in appsettings.json or via environment variables."); + } + + // Register HTTP client for API calls with connection pooling + services.AddHttpClient("ResgridApi", client => + { + client.BaseAddress = new Uri(apiBaseUrl); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + client.Timeout = TimeSpan.FromSeconds(30); + }) + .ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), + MaxConnectionsPerServer = 10 + }); + + // Register API client + services.AddSingleton(); + + // Register tool providers + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }); + } +} + + + diff --git a/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj b/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj new file mode 100644 index 00000000..c7b19e35 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj @@ -0,0 +1,58 @@ + + + Model Context Protocol (MCP) Server for Resgrid CAD System + 1.0.0 + Resgrid, LLC. + net9.0 + Exe + Resgrid.Web.Mcp + Resgrid.Web.Mcp + Resgrid.Web.Mcp.Program + Debug;Release;Docker + + + DOCKER + + + + PreserveNewest + true + PreserveNewest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Web/Resgrid.Web.Mcp/Tools/AuthenticationToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/AuthenticationToolProvider.cs new file mode 100644 index 00000000..8793239e --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Tools/AuthenticationToolProvider.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using Newtonsoft.Json; + +namespace Resgrid.Web.Mcp.Tools +{ + /// + /// Provides authentication tools for MCP clients + /// + public sealed class AuthenticationToolProvider + { + private readonly IApiClient _apiClient; + private readonly ILogger _logger; + private const string TOOL_NAME = "authenticate"; + + public AuthenticationToolProvider(IApiClient apiClient, ILogger logger) + { + _apiClient = apiClient; + _logger = logger; + } + + public void RegisterTools(McpServer server) + { + server.AddTool( + TOOL_NAME, + "Authenticates a user with the Resgrid CAD system and returns an access token for subsequent operations", + new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary + { + ["username"] = new Dictionary + { + ["type"] = "string", + ["description"] = "The username or email address of the user" + }, + ["password"] = new Dictionary + { + ["type"] = "string", + ["description"] = "The user's password" + } + }, + ["required"] = new[] { "username", "password" } + }, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.Username) || string.IsNullOrWhiteSpace(args?.Password)) + { + return new + { + success = false, + error = "Username and password are required" + }; + } + + var userIdentifier = ComputeUserIdentifier(args.Username); + _logger.LogInformation("Authentication attempt for user identifier: {UserIdentifier}", userIdentifier); + + var result = await _apiClient.AuthenticateAsync(args.Username, args.Password); + + if (result.IsSuccess) + { + _logger.LogInformation("Authentication successful for user identifier: {UserIdentifier}", userIdentifier); + return new + { + success = true, + accessToken = result.AccessToken, + tokenType = result.TokenType, + expiresIn = result.ExpiresIn, + message = "Authentication successful. Use this access token in subsequent API calls." + }; + } + else + { + _logger.LogWarning("Authentication failed for user identifier: {UserIdentifier}", userIdentifier); + return new + { + success = false, + error = result.ErrorMessage ?? "Authentication failed" + }; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in authentication tool"); + return new + { + success = false, + error = "Authentication failed. Please check your credentials and try again." + }; + } + } + ); + } + + public IEnumerable GetToolNames() + { + yield return TOOL_NAME; + } + + /// + /// Computes a deterministic, non-reversible hash of the username for logging without exposing PII. + /// Uses HMAC-SHA256 with a fixed key and truncates to 12 characters for readability. + /// + /// The username to hash + /// A truncated hash that can be used for correlation in logs + private static string ComputeUserIdentifier(string username) + { + if (string.IsNullOrWhiteSpace(username)) + { + return "unknown"; + } + + // Fixed key for deterministic hashing - this allows correlation across logs + // while preventing reversibility + var key = Encoding.UTF8.GetBytes("Resgrid-MCP-Auth-Log-Salt-2026"); + var data = Encoding.UTF8.GetBytes(username.ToLowerInvariant()); + + using (var hmac = new HMACSHA256(key)) + { + var hash = hmac.ComputeHash(data); + var hashString = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + // Truncate to 12 characters for readability while maintaining uniqueness + return hashString.Substring(0, 12); + } + } + + private sealed class AuthenticateArgs + { + [JsonProperty("username")] + public string Username { get; set; } + + [JsonProperty("password")] + public string Password { get; set; } + } + } +} + + + diff --git a/Web/Resgrid.Web.Mcp/Tools/CalendarToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/CalendarToolProvider.cs new file mode 100644 index 00000000..ef428083 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Tools/CalendarToolProvider.cs @@ -0,0 +1,333 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using Newtonsoft.Json; + +namespace Resgrid.Web.Mcp.Tools +{ + /// + /// Provides MCP tools for calendar management in the Resgrid system + /// + public sealed class CalendarToolProvider + { + private readonly IApiClient _apiClient; + private readonly ILogger _logger; + private readonly List _toolNames; + + public CalendarToolProvider(IApiClient apiClient, ILogger logger) + { + _apiClient = apiClient; + _logger = logger; + _toolNames = new List(); + } + + public void RegisterTools(McpServer server) + { + RegisterGetCalendarItemsTool(server); + RegisterCreateCalendarItemTool(server); + RegisterUpdateCalendarItemTool(server); + RegisterDeleteCalendarItemTool(server); + } + + public IEnumerable GetToolNames() => _toolNames; + + private void RegisterGetCalendarItemsTool(McpServer server) + { + const string toolName = "get_calendar_items"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["startDate"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Start date (ISO 8601 format)" }, + ["endDate"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "End date (ISO 8601 format)" } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Retrieves calendar items for the department within a date range", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving calendar items"); + + var endpoint = "/api/v4/Calendar/GetItems"; + if (!string.IsNullOrWhiteSpace(args.StartDate) && !string.IsNullOrWhiteSpace(args.EndDate)) + { + endpoint += $"?startDate={Uri.EscapeDataString(args.StartDate)}&endDate={Uri.EscapeDataString(args.EndDate)}"; + } + + var result = await _apiClient.GetAsync(endpoint, args.AccessToken); + + return new { success = true, data = result }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving calendar items"); + return CreateErrorResponse("Failed to retrieve calendar items. Please try again later."); + } + } + ); + } + + private void RegisterCreateCalendarItemTool(McpServer server) + { + const string toolName = "create_calendar_item"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["title"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Calendar item title" }, + ["description"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Calendar item description" }, + ["startDate"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Start date and time (ISO 8601 format)" }, + ["endDate"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "End date and time (ISO 8601 format)" }, + ["location"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Event location" } + }, + new[] { "accessToken", "title", "startDate", "endDate" } + ); + + server.AddTool( + toolName, + "Creates a new calendar item", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Creating calendar item: {Title}", args.Title); + + var itemData = new + { + title = args.Title, + description = args.Description, + startDate = args.StartDate, + endDate = args.EndDate, + location = args.Location + }; + + var result = await _apiClient.PostAsync( + "/api/v4/Calendar/CreateItem", + itemData, + args.AccessToken + ); + + return new { success = true, data = result, message = "Calendar item created successfully" }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating calendar item"); + return CreateErrorResponse("Failed to create calendar item. Please try again later."); + } + } + ); + } + + private void RegisterUpdateCalendarItemTool(McpServer server) + { + const string toolName = "update_calendar_item"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["itemId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Calendar item ID" }, + ["title"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Calendar item title" }, + ["description"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Calendar item description" }, + ["startDate"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Start date and time (ISO 8601 format)" }, + ["endDate"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "End date and time (ISO 8601 format)" } + }, + new[] { "accessToken", "itemId" } + ); + + server.AddTool( + toolName, + "Updates an existing calendar item", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + if (args.ItemId <= 0) + { + return CreateErrorResponse("Valid calendar item ID is required"); + } + + _logger.LogInformation("Updating calendar item {ItemId}", args.ItemId); + + var itemData = new + { + itemId = args.ItemId, + title = args.Title, + description = args.Description, + startDate = args.StartDate, + endDate = args.EndDate + }; + + var result = await _apiClient.PutAsync( + "/api/v4/Calendar/UpdateItem", + itemData, + args.AccessToken + ); + + return new { success = true, data = result, message = "Calendar item updated successfully" }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating calendar item"); + return CreateErrorResponse("Failed to update calendar item. Please try again later."); + } + } + ); + } + + private void RegisterDeleteCalendarItemTool(McpServer server) + { + const string toolName = "delete_calendar_item"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["itemId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Calendar item ID to delete" } + }, + new[] { "accessToken", "itemId" } + ); + + server.AddTool( + toolName, + "Deletes a calendar item", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + if (args.ItemId <= 0) + { + return CreateErrorResponse("Valid calendar item ID is required"); + } + + _logger.LogInformation("Deleting calendar item {ItemId}", args.ItemId); + + var success = await _apiClient.DeleteAsync( + $"/api/v4/Calendar/DeleteItem?itemId={args.ItemId}", + args.AccessToken + ); + + return new { success, message = success ? "Calendar item deleted successfully" : "Failed to delete calendar item" }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting calendar item"); + return CreateErrorResponse("Failed to delete calendar item. Please try again later."); + } + } + ); + } + + private static object CreateErrorResponse(string errorMessage) => + new { success = false, error = errorMessage }; + + private sealed class GetCalendarArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("startDate")] + public string StartDate { get; set; } + + [JsonProperty("endDate")] + public string EndDate { get; set; } + } + + private sealed class CreateCalendarArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("startDate")] + public string StartDate { get; set; } + + [JsonProperty("endDate")] + public string EndDate { get; set; } + + [JsonProperty("location")] + public string Location { get; set; } + } + + private sealed class UpdateCalendarArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("itemId")] + public int ItemId { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("startDate")] + public string StartDate { get; set; } + + [JsonProperty("endDate")] + public string EndDate { get; set; } + } + + private sealed class CalendarIdArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("itemId")] + public int ItemId { get; set; } + } + } +} + diff --git a/Web/Resgrid.Web.Mcp/Tools/CallsToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/CallsToolProvider.cs new file mode 100644 index 00000000..952f7625 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Tools/CallsToolProvider.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using Newtonsoft.Json; + +namespace Resgrid.Web.Mcp.Tools +{ + /// + /// Provides MCP tools for managing calls (dispatches) in the Resgrid system + /// + public sealed class CallsToolProvider + { + private readonly IApiClient _apiClient; + private readonly ILogger _logger; + private readonly List _toolNames; + + public CallsToolProvider(IApiClient apiClient, ILogger logger) + { + _apiClient = apiClient; + _logger = logger; + _toolNames = new List(); + } + + public void RegisterTools(McpServer server) + { + RegisterGetActiveCallsTool(server); + RegisterGetCallDetailsTool(server); + RegisterCreateCallTool(server); + RegisterCloseCallTool(server); + } + + public IEnumerable GetToolNames() => _toolNames; + + private void RegisterGetActiveCallsTool(McpServer server) + { + const string toolName = "get_active_calls"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema + { + Type = "string", + Description = "OAuth2 access token obtained from authentication" + } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Retrieves all active calls (dispatches) for the user's department in the Resgrid CAD system", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving active calls"); + + var result = await _apiClient.GetAsync( + "/api/v4/Calls/GetActiveCalls", + args.AccessToken + ); + + return new + { + success = true, + data = result + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving active calls"); + return CreateErrorResponse("Failed to retrieve active calls. Please try again later."); + } + } + ); + } + + private void RegisterGetCallDetailsTool(McpServer server) + { + const string toolName = "get_call_details"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["callId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "The unique identifier of the call" } + }, + new[] { "accessToken", "callId" } + ); + + server.AddTool( + toolName, + "Retrieves detailed information about a specific call by its ID", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + if (args.CallId <= 0) + { + return CreateErrorResponse("Valid call ID is required"); + } + + _logger.LogInformation("Retrieving call details for call {CallId}", args.CallId); + + var result = await _apiClient.GetAsync( + $"/api/v4/Calls/GetCall?callId={args.CallId}", + args.AccessToken + ); + + return new + { + success = true, + data = result + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving call details"); + return CreateErrorResponse("Failed to retrieve call details. Please try again later."); + } + } + ); + } + + private void RegisterCreateCallTool(McpServer server) + { + const string toolName = "create_call"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["name"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Name or title of the call" }, + ["nature"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Nature of the call (e.g., 'Fire', 'Medical Emergency', 'Motor Vehicle Accident')" }, + ["address"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Address or location of the incident" }, + ["notes"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Additional notes or details about the call" }, + ["priority"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Priority level of the call (higher number = higher priority)" } + }, + new[] { "accessToken", "name", "nature" } + ); + + server.AddTool( + toolName, + "Creates a new call (dispatch) in the Resgrid CAD system", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + if (string.IsNullOrWhiteSpace(args?.Name)) + { + return CreateErrorResponse("Call name is required"); + } + + if (string.IsNullOrWhiteSpace(args?.Nature)) + { + return CreateErrorResponse("Call nature is required"); + } + + _logger.LogInformation("Creating new call: {CallName}", args.Name); + + var callData = new + { + name = args.Name, + nature = args.Nature, + address = args.Address, + notes = args.Notes, + priority = args.Priority + }; + + var result = await _apiClient.PostAsync( + "/api/v4/Calls/NewCall", + callData, + args.AccessToken + ); + + return new + { + success = true, + data = result, + message = "Call created successfully" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating call"); + return CreateErrorResponse("Failed to create call. Please try again later."); + } + } + ); + } + + private void RegisterCloseCallTool(McpServer server) + { + const string toolName = "close_call"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["callId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "The unique identifier of the call to close" }, + ["note"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Optional closing note or comment" } + }, + new[] { "accessToken", "callId" } + ); + + server.AddTool( + toolName, + "Closes an active call in the Resgrid CAD system", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + if (args.CallId <= 0) + { + return CreateErrorResponse("Valid call ID is required"); + } + + _logger.LogInformation("Closing call {CallId}", args.CallId); + + var closeData = new + { + callId = args.CallId, + note = args.Note + }; + + var result = await _apiClient.PutAsync( + "/api/v4/Calls/CloseCall", + closeData, + args.AccessToken + ); + + return new + { + success = true, + data = result, + message = "Call closed successfully" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error closing call"); + return CreateErrorResponse("Failed to close call. Please try again later."); + } + } + ); + } + + private static object CreateErrorResponse(string errorMessage) => + new { success = false, error = errorMessage }; + + private sealed class TokenArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + } + + private sealed class CallIdArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("callId")] + public int CallId { get; set; } + } + + private sealed class CreateCallArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("nature")] + public string Nature { get; set; } + + [JsonProperty("address")] + public string Address { get; set; } + + [JsonProperty("notes")] + public string Notes { get; set; } + + [JsonProperty("priority")] + public int Priority { get; set; } + } + + private sealed class CloseCallArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("callId")] + public int CallId { get; set; } + + [JsonProperty("note")] + public string Note { get; set; } + } + } +} + + + + + + + + + diff --git a/Web/Resgrid.Web.Mcp/Tools/DispatchToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/DispatchToolProvider.cs new file mode 100644 index 00000000..b42a9dba --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Tools/DispatchToolProvider.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using Newtonsoft.Json; + +namespace Resgrid.Web.Mcp.Tools +{ + /// + /// Provides MCP tools for dispatch operations in the Resgrid system + /// + public sealed class DispatchToolProvider + { + private readonly IApiClient _apiClient; + private readonly ILogger _logger; + private readonly List _toolNames; + + public DispatchToolProvider(IApiClient apiClient, ILogger logger) + { + _apiClient = apiClient; + _logger = logger; + _toolNames = new List(); + } + + public void RegisterTools(McpServer server) + { + RegisterDispatchCallTool(server); + RegisterGetDispatchStatusTool(server); + } + + public IEnumerable GetToolNames() => _toolNames; + + private void RegisterDispatchCallTool(McpServer server) + { + const string toolName = "dispatch_call"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["callId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "The unique identifier of the call to dispatch" }, + ["groupIds"] = new SchemaBuilder.PropertySchema { Type = "array", Items = "integer", Description = "Optional array of group IDs to dispatch" }, + ["unitIds"] = new SchemaBuilder.PropertySchema { Type = "array", Items = "integer", Description = "Optional array of unit IDs to dispatch" }, + ["personnelIds"] = new SchemaBuilder.PropertySchema { Type = "array", Items = "string", Description = "Optional array of personnel user IDs to dispatch" } + }, + new[] { "accessToken", "callId" } + ); + + server.AddTool( + toolName, + "Dispatches personnel and units to an active call", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + if (args.CallId <= 0) + { + return CreateErrorResponse("Valid call ID is required"); + } + + _logger.LogInformation("Dispatching to call {CallId}", args.CallId); + + var dispatchData = new + { + callId = args.CallId, + groupIds = args.GroupIds, + unitIds = args.UnitIds, + personnelIds = args.PersonnelIds + }; + + var result = await _apiClient.PostAsync( + "/api/v4/Dispatch/DispatchCall", + dispatchData, + args.AccessToken + ); + + return new + { + success = true, + data = result, + message = "Dispatch successful" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error dispatching call"); + return CreateErrorResponse("Failed to dispatch call. Please try again later."); + } + } + ); + } + + private void RegisterGetDispatchStatusTool(McpServer server) + { + const string toolName = "get_dispatch_status"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Gets the current dispatch status for personnel and units in the department", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving dispatch status"); + + var result = await _apiClient.GetAsync( + "/api/v4/Personnel/GetPersonnelStatuses", + args.AccessToken + ); + + return new + { + success = true, + data = result + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving dispatch status"); + return CreateErrorResponse("Failed to retrieve dispatch status. Please try again later."); + } + } + ); + } + + private static object CreateErrorResponse(string errorMessage) => + new { success = false, error = errorMessage }; + + private sealed class TokenArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + } + + private sealed class DispatchCallArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("callId")] + public int CallId { get; set; } + + [JsonProperty("groupIds")] + public int[] GroupIds { get; set; } + + [JsonProperty("unitIds")] + public int[] UnitIds { get; set; } + + [JsonProperty("personnelIds")] + public string[] PersonnelIds { get; set; } + } + } +} + + + + + diff --git a/Web/Resgrid.Web.Mcp/Tools/InventoryToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/InventoryToolProvider.cs new file mode 100644 index 00000000..8709ef76 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Tools/InventoryToolProvider.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using Newtonsoft.Json; + +namespace Resgrid.Web.Mcp.Tools +{ + /// + /// Provides MCP tools for inventory management in the Resgrid system + /// + public sealed class InventoryToolProvider + { + private readonly IApiClient _apiClient; + private readonly ILogger _logger; + private readonly List _toolNames; + + public InventoryToolProvider(IApiClient apiClient, ILogger logger) + { + _apiClient = apiClient; + _logger = logger; + _toolNames = new List(); + } + + public void RegisterTools(McpServer server) + { + RegisterGetInventoryTool(server); + RegisterGetInventoryItemTool(server); + RegisterUpdateInventoryTool(server); + RegisterLowStockItemsTool(server); + } + + public IEnumerable GetToolNames() => _toolNames; + + private void RegisterGetInventoryTool(McpServer server) + { + const string toolName = "get_inventory"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Retrieves all inventory items for the department", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving inventory"); + + var result = await _apiClient.GetAsync( + "/api/v4/Inventory/GetAll", + args.AccessToken + ); + + return new { success = true, data = result }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving inventory"); + return CreateErrorResponse("Failed to retrieve inventory. Please try again later."); + } + } + ); + } + + private void RegisterGetInventoryItemTool(McpServer server) + { + const string toolName = "get_inventory_item"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["itemId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Inventory item ID" } + }, + new[] { "accessToken", "itemId" } + ); + + server.AddTool( + toolName, + "Retrieves details of a specific inventory item", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving inventory item {ItemId}", args.ItemId); + + var result = await _apiClient.GetAsync( + $"/api/v4/Inventory/GetItem?itemId={args.ItemId}", + args.AccessToken + ); + + return new { success = true, data = result }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving inventory item"); + return CreateErrorResponse("Failed to retrieve inventory item. Please try again later."); + } + } + ); + } + + private void RegisterUpdateInventoryTool(McpServer server) + { + const string toolName = "update_inventory"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["itemId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Inventory item ID" }, + ["quantity"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "New quantity" }, + ["note"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Optional note about the update" } + }, + new[] { "accessToken", "itemId", "quantity" } + ); + + server.AddTool( + toolName, + "Updates the quantity of an inventory item", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Updating inventory item {ItemId}", args.ItemId); + + var updateData = new + { + itemId = args.ItemId, + quantity = args.Quantity, + note = args.Note + }; + + var result = await _apiClient.PutAsync( + "/api/v4/Inventory/UpdateItem", + updateData, + args.AccessToken + ); + + return new { success = true, data = result, message = "Inventory updated successfully" }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating inventory"); + return CreateErrorResponse("Failed to update inventory. Please try again later."); + } + } + ); + } + + private void RegisterLowStockItemsTool(McpServer server) + { + const string toolName = "get_low_stock_items"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Retrieves all inventory items that are low in stock", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving low stock items"); + + var result = await _apiClient.GetAsync( + "/api/v4/Inventory/GetLowStockItems", + args.AccessToken + ); + + return new { success = true, data = result }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving low stock items"); + return CreateErrorResponse("Failed to retrieve low stock items. Please try again later."); + } + } + ); + } + + private static object CreateErrorResponse(string errorMessage) => + new { success = false, error = errorMessage }; + + private sealed class TokenArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + } + + private sealed class ItemIdArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("itemId")] + public int ItemId { get; set; } + } + + private sealed class UpdateInventoryArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("itemId")] + public int ItemId { get; set; } + + [JsonProperty("quantity")] + public int Quantity { get; set; } + + [JsonProperty("note")] + public string Note { get; set; } + } + } +} + diff --git a/Web/Resgrid.Web.Mcp/Tools/MessagesToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/MessagesToolProvider.cs new file mode 100644 index 00000000..1547e5c7 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Tools/MessagesToolProvider.cs @@ -0,0 +1,329 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using Newtonsoft.Json; + +namespace Resgrid.Web.Mcp.Tools +{ + /// + /// Provides MCP tools for message management in the Resgrid system + /// + public sealed class MessagesToolProvider + { + private readonly IApiClient _apiClient; + private readonly ILogger _logger; + private readonly List _toolNames; + + public MessagesToolProvider(IApiClient apiClient, ILogger logger) + { + _apiClient = apiClient; + _logger = logger; + _toolNames = new List(); + } + + public void RegisterTools(McpServer server) + { + RegisterGetInboxTool(server); + RegisterGetOutboxTool(server); + RegisterSendMessageTool(server); + RegisterGetMessageTool(server); + RegisterDeleteMessageTool(server); + } + + public IEnumerable GetToolNames() => _toolNames; + + private void RegisterGetInboxTool(McpServer server) + { + const string toolName = "get_inbox"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Retrieves all inbox messages for the user", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving inbox messages"); + + var result = await _apiClient.GetAsync( + "/api/v4/Inbox/GetInbox", + args.AccessToken + ); + + return new { success = true, data = result }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving inbox"); + return CreateErrorResponse("Failed to retrieve inbox. Please try again later."); + } + } + ); + } + + private void RegisterGetOutboxTool(McpServer server) + { + const string toolName = "get_outbox"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Retrieves all sent messages (outbox) for the user", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving outbox messages"); + + var result = await _apiClient.GetAsync( + "/api/v4/Messages/GetSentMessages", + args.AccessToken + ); + + return new { success = true, data = result }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving outbox"); + return CreateErrorResponse("Failed to retrieve outbox. Please try again later."); + } + } + ); + } + + private void RegisterSendMessageTool(McpServer server) + { + const string toolName = "send_message"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["subject"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Message subject" }, + ["body"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Message body/content" }, + ["recipients"] = new SchemaBuilder.PropertySchema { Type = "array", Items = "string", Description = "Array of recipient user IDs" } + }, + new[] { "accessToken", "subject", "body", "recipients" } + ); + + server.AddTool( + toolName, + "Sends a message to specified recipients", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + if (string.IsNullOrWhiteSpace(args.Subject)) + { + return CreateErrorResponse("Subject is required"); + } + + if (string.IsNullOrWhiteSpace(args.Body)) + { + return CreateErrorResponse("Message body is required"); + } + + if (args.Recipients == null || args.Recipients.Length == 0) + { + return CreateErrorResponse("At least one recipient is required"); + } + + _logger.LogInformation("Sending message: {Subject}", args.Subject); + + var messageData = new + { + subject = args.Subject, + body = args.Body, + recipients = args.Recipients + }; + + var result = await _apiClient.PostAsync( + "/api/v4/Messages/SendMessage", + messageData, + args.AccessToken + ); + + return new { success = true, data = result, message = "Message sent successfully" }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending message"); + return CreateErrorResponse("Failed to send message. Please try again later."); + } + } + ); + } + + private void RegisterGetMessageTool(McpServer server) + { + const string toolName = "get_message"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["messageId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "The unique identifier of the message" } + }, + new[] { "accessToken", "messageId" } + ); + + server.AddTool( + toolName, + "Retrieves details of a specific message by ID", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving message {MessageId}", args.MessageId); + + var result = await _apiClient.GetAsync( + $"/api/v4/Messages/GetMessage?messageId={args.MessageId}", + args.AccessToken + ); + + return new { success = true, data = result }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving message"); + return CreateErrorResponse("Failed to retrieve message. Please try again later."); + } + } + ); + } + + private void RegisterDeleteMessageTool(McpServer server) + { + const string toolName = "delete_message"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["messageId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "The unique identifier of the message to delete" } + }, + new[] { "accessToken", "messageId" } + ); + + server.AddTool( + toolName, + "Deletes a message", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Deleting message {MessageId}", args.MessageId); + + var success = await _apiClient.DeleteAsync( + $"/api/v4/Messages/DeleteMessage?messageId={args.MessageId}", + args.AccessToken + ); + + return new { success, message = success ? "Message deleted successfully" : "Failed to delete message" }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting message"); + return CreateErrorResponse("Failed to delete message. Please try again later."); + } + } + ); + } + + private static object CreateErrorResponse(string errorMessage) => + new { success = false, error = errorMessage }; + + private sealed class TokenArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + } + + private sealed class MessageIdArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("messageId")] + public int MessageId { get; set; } + } + + private sealed class SendMessageArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("subject")] + public string Subject { get; set; } + + [JsonProperty("body")] + public string Body { get; set; } + + [JsonProperty("recipients")] + public string[] Recipients { get; set; } + } + } +} + diff --git a/Web/Resgrid.Web.Mcp/Tools/PersonnelToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/PersonnelToolProvider.cs new file mode 100644 index 00000000..7df3db64 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Tools/PersonnelToolProvider.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using Newtonsoft.Json; + +namespace Resgrid.Web.Mcp.Tools +{ + /// + /// Provides MCP tools for managing personnel in the Resgrid system + /// + public sealed class PersonnelToolProvider + { + private readonly IApiClient _apiClient; + private readonly ILogger _logger; + private readonly List _toolNames; + + public PersonnelToolProvider(IApiClient apiClient, ILogger logger) + { + _apiClient = apiClient; + _logger = logger; + _toolNames = new List(); + } + + public void RegisterTools(McpServer server) + { + RegisterGetPersonnelTool(server); + RegisterGetPersonnelStatusTool(server); + RegisterSetPersonnelStatusTool(server); + RegisterGetPersonnelLocationTool(server); + } + + public IEnumerable GetToolNames() => _toolNames; + + private void RegisterGetPersonnelTool(McpServer server) + { + const string toolName = "get_personnel"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Retrieves all personnel (members) in the user's department", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving personnel list"); + + var result = await _apiClient.GetAsync( + "/api/v4/Personnel/GetAll", + args.AccessToken + ); + + return new + { + success = true, + data = result + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving personnel"); + return CreateErrorResponse("Failed to retrieve personnel. Please try again later."); + } + } + ); + } + + private void RegisterGetPersonnelStatusTool(McpServer server) + { + const string toolName = "get_personnel_status"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Retrieves the current status of all personnel in the department", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving personnel statuses"); + + var result = await _apiClient.GetAsync( + "/api/v4/PersonnelStatuses/GetAllStatuses", + args.AccessToken + ); + + return new + { + success = true, + data = result + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving personnel statuses"); + return CreateErrorResponse("Failed to retrieve personnel statuses. Please try again later."); + } + } + ); + } + + private void RegisterSetPersonnelStatusTool(McpServer server) + { + const string toolName = "set_personnel_status"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["userId"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "User ID of the personnel member" }, + ["statusType"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Status type code (0=Unavailable, 1=Available, 2=Committed, 3=OnScene, 4=Responding, 5=Standing By, 6=Not Responding)" }, + ["note"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Optional note about the status change" } + }, + new[] { "accessToken", "userId", "statusType" } + ); + + server.AddTool( + toolName, + "Sets the status for personnel (e.g., Available, Responding, On Scene)", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + if (string.IsNullOrWhiteSpace(args?.UserId)) + { + return CreateErrorResponse("User ID is required"); + } + + if (args.StatusType < 0 || args.StatusType > 6) + { + return CreateErrorResponse("StatusType must be between 0 and 6 (0=Unavailable, 1=Available, 2=Committed, 3=OnScene, 4=Responding, 5=Standing By, 6=Not Responding)"); + } + + _logger.LogInformation("Setting status for personnel {UserId}", args.UserId); + + var statusData = new + { + userId = args.UserId, + type = args.StatusType, + note = args.Note + }; + + var result = await _apiClient.PostAsync( + "/api/v4/PersonnelStatuses/SetPersonnelStatus", + statusData, + args.AccessToken + ); + + return new + { + success = true, + data = result, + message = "Personnel status updated successfully" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error setting personnel status"); + return CreateErrorResponse("Failed to set personnel status. Please try again later."); + } + } + ); + } + + private void RegisterGetPersonnelLocationTool(McpServer server) + { + const string toolName = "get_personnel_locations"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Retrieves the current GPS locations of all personnel in the department", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving personnel locations"); + + var result = await _apiClient.GetAsync( + "/api/v4/PersonnelLocation/GetLatestLocations", + args.AccessToken + ); + + return new + { + success = true, + data = result + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving personnel locations"); + return CreateErrorResponse("Failed to retrieve personnel locations. Please try again later."); + } + } + ); + } + + private static object CreateErrorResponse(string errorMessage) => + new { success = false, error = errorMessage }; + + private sealed class TokenArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + } + + private sealed class SetPersonnelStatusArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("userId")] + public string UserId { get; set; } + + [JsonProperty("statusType")] + public int StatusType { get; set; } + + [JsonProperty("note")] + public string Note { get; set; } + } + } +} + + + + + + + diff --git a/Web/Resgrid.Web.Mcp/Tools/ReportsToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/ReportsToolProvider.cs new file mode 100644 index 00000000..38e66431 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Tools/ReportsToolProvider.cs @@ -0,0 +1,376 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using Newtonsoft.Json; + +namespace Resgrid.Web.Mcp.Tools +{ + /// + /// Provides MCP tools for report generation in the Resgrid system + /// + public sealed class ReportsToolProvider + { + private readonly IApiClient _apiClient; + private readonly ILogger _logger; + private readonly List _toolNames; + + public ReportsToolProvider(IApiClient apiClient, ILogger logger) + { + _apiClient = apiClient; + _logger = logger; + _toolNames = new List(); + } + + public void RegisterTools(McpServer server) + { + RegisterGenerateCallsReportTool(server); + RegisterGeneratePersonnelReportTool(server); + RegisterGenerateUnitsReportTool(server); + RegisterGenerateActivityReportTool(server); + RegisterGetAvailableReportsTool(server); + } + + public IEnumerable GetToolNames() => _toolNames; + + private void RegisterGenerateCallsReportTool(McpServer server) + { + const string toolName = "generate_calls_report"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["startDate"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Start date for report (ISO 8601 format)" }, + ["endDate"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "End date for report (ISO 8601 format)" }, + ["format"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Report format: 'pdf', 'excel', or 'json' (default: json)" } + }, + new[] { "accessToken", "startDate", "endDate" } + ); + + server.AddTool( + toolName, + "Generates a calls/dispatches report for the specified date range", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + if (string.IsNullOrWhiteSpace(args.StartDate)) + { + return CreateErrorResponse("Start date is required"); + } + + if (string.IsNullOrWhiteSpace(args.EndDate)) + { + return CreateErrorResponse("End date is required"); + } + + _logger.LogInformation("Generating calls report from {StartDate} to {EndDate}", args.StartDate, args.EndDate); + + var reportData = new + { + startDate = args.StartDate, + endDate = args.EndDate, + format = args.Format ?? "json" + }; + + var result = await _apiClient.PostAsync( + "/api/v4/Reports/GenerateCallsReport", + reportData, + args.AccessToken + ); + + return new { success = true, data = result, message = "Calls report generated successfully" }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating calls report"); + return CreateErrorResponse("Failed to generate calls report. Please try again later."); + } + } + ); + } + + private void RegisterGeneratePersonnelReportTool(McpServer server) + { + const string toolName = "generate_personnel_report"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["startDate"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Start date for report (ISO 8601 format)" }, + ["endDate"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "End date for report (ISO 8601 format)" }, + ["format"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Report format: 'pdf', 'excel', or 'json' (default: json)" } + }, + new[] { "accessToken", "startDate", "endDate" } + ); + + server.AddTool( + toolName, + "Generates a personnel activity report for the specified date range", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + if (string.IsNullOrWhiteSpace(args.StartDate)) + { + return CreateErrorResponse("Start date is required"); + } + + if (string.IsNullOrWhiteSpace(args.EndDate)) + { + return CreateErrorResponse("End date is required"); + } + + _logger.LogInformation("Generating personnel report from {StartDate} to {EndDate}", args.StartDate, args.EndDate); + + var reportData = new + { + startDate = args.StartDate, + endDate = args.EndDate, + format = args.Format ?? "json" + }; + + var result = await _apiClient.PostAsync( + "/api/v4/Reports/GeneratePersonnelReport", + reportData, + args.AccessToken + ); + + return new { success = true, data = result, message = "Personnel report generated successfully" }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating personnel report"); + return CreateErrorResponse("Failed to generate personnel report. Please try again later."); + } + } + ); + } + + private void RegisterGenerateUnitsReportTool(McpServer server) + { + const string toolName = "generate_units_report"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["startDate"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Start date for report (ISO 8601 format)" }, + ["endDate"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "End date for report (ISO 8601 format)" }, + ["format"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Report format: 'pdf', 'excel', or 'json' (default: json)" } + }, + new[] { "accessToken", "startDate", "endDate" } + ); + + server.AddTool( + toolName, + "Generates a units/apparatus activity report for the specified date range", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + if (string.IsNullOrWhiteSpace(args.StartDate)) + { + return CreateErrorResponse("Start date is required"); + } + + if (string.IsNullOrWhiteSpace(args.EndDate)) + { + return CreateErrorResponse("End date is required"); + } + + _logger.LogInformation("Generating units report from {StartDate} to {EndDate}", args.StartDate, args.EndDate); + + var reportData = new + { + startDate = args.StartDate, + endDate = args.EndDate, + format = args.Format ?? "json" + }; + + var result = await _apiClient.PostAsync( + "/api/v4/Reports/GenerateUnitsReport", + reportData, + args.AccessToken + ); + + return new { success = true, data = result, message = "Units report generated successfully" }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating units report"); + return CreateErrorResponse("Failed to generate units report. Please try again later."); + } + } + ); + } + + private void RegisterGenerateActivityReportTool(McpServer server) + { + const string toolName = "generate_activity_report"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["startDate"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Start date for report (ISO 8601 format)" }, + ["endDate"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "End date for report (ISO 8601 format)" }, + ["format"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Report format: 'pdf', 'excel', or 'json' (default: json)" } + }, + new[] { "accessToken", "startDate", "endDate" } + ); + + server.AddTool( + toolName, + "Generates a comprehensive activity report covering calls, personnel, and units", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + if (string.IsNullOrWhiteSpace(args.StartDate)) + { + return CreateErrorResponse("Start date is required"); + } + + if (string.IsNullOrWhiteSpace(args.EndDate)) + { + return CreateErrorResponse("End date is required"); + } + + _logger.LogInformation("Generating activity report from {StartDate} to {EndDate}", args.StartDate, args.EndDate); + + var reportData = new + { + startDate = args.StartDate, + endDate = args.EndDate, + format = args.Format ?? "json" + }; + + var result = await _apiClient.PostAsync( + "/api/v4/Reports/GenerateActivityReport", + reportData, + args.AccessToken + ); + + return new { success = true, data = result, message = "Activity report generated successfully" }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating activity report"); + return CreateErrorResponse("Failed to generate activity report. Please try again later."); + } + } + ); + } + + private void RegisterGetAvailableReportsTool(McpServer server) + { + const string toolName = "get_available_reports"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Retrieves a list of all available report types and previously generated reports", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving available reports"); + + var result = await _apiClient.GetAsync( + "/api/v4/Reports/GetAvailableReports", + args.AccessToken + ); + + return new { success = true, data = result }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving available reports"); + return CreateErrorResponse("Failed to retrieve available reports. Please try again later."); + } + } + ); + } + + private static object CreateErrorResponse(string errorMessage) => + new { success = false, error = errorMessage }; + + private sealed class TokenArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + } + + private sealed class GenerateReportArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("startDate")] + public string StartDate { get; set; } + + [JsonProperty("endDate")] + public string EndDate { get; set; } + + [JsonProperty("format")] + public string Format { get; set; } + } + } +} + diff --git a/Web/Resgrid.Web.Mcp/Tools/SchemaBuilder.cs b/Web/Resgrid.Web.Mcp/Tools/SchemaBuilder.cs new file mode 100644 index 00000000..f8f426e9 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Tools/SchemaBuilder.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; + +namespace Resgrid.Web.Mcp.Tools +{ + /// + /// Helper class for building MCP tool schemas + /// + public static class SchemaBuilder + { + public static Dictionary BuildObjectSchema( + Dictionary properties, + string[] required = null) + { + var schema = new Dictionary + { + ["type"] = "object", + ["properties"] = BuildProperties(properties) + }; + + if (required != null && required.Length > 0) + { + schema["required"] = required; + } + + return schema; + } + + private static Dictionary BuildProperties(Dictionary properties) + { + var props = new Dictionary(); + foreach (var kvp in properties) + { + var propertyDict = new Dictionary + { + ["type"] = kvp.Value.Type, + ["description"] = kvp.Value.Description + }; + + if (kvp.Value.Items != null) + { + propertyDict["items"] = new Dictionary { ["type"] = kvp.Value.Items }; + } + + props[kvp.Key] = propertyDict; + } + return props; + } + + public sealed class PropertySchema + { + public string Type { get; set; } + public string Description { get; set; } + public string Items { get; set; } // For array types + } + } +} + diff --git a/Web/Resgrid.Web.Mcp/Tools/ShiftsToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/ShiftsToolProvider.cs new file mode 100644 index 00000000..61a3b17f --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Tools/ShiftsToolProvider.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using Newtonsoft.Json; + +namespace Resgrid.Web.Mcp.Tools +{ + /// + /// Provides MCP tools for shift management in the Resgrid system + /// + public sealed class ShiftsToolProvider + { + private readonly IApiClient _apiClient; + private readonly ILogger _logger; + private readonly List _toolNames; + + public ShiftsToolProvider(IApiClient apiClient, ILogger logger) + { + _apiClient = apiClient; + _logger = logger; + _toolNames = new List(); + } + + public void RegisterTools(McpServer server) + { + RegisterGetShiftsTool(server); + RegisterGetShiftDetailsTool(server); + RegisterGetCurrentShiftTool(server); + RegisterSignupForShiftTool(server); + } + + public IEnumerable GetToolNames() => _toolNames; + + private void RegisterGetShiftsTool(McpServer server) + { + const string toolName = "get_shifts"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Retrieves all shifts for the department", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving shifts"); + + var result = await _apiClient.GetAsync( + "/api/v4/Shifts/GetShifts", + args.AccessToken + ); + + return new { success = true, data = result }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving shifts"); + return CreateErrorResponse("Failed to retrieve shifts. Please try again later."); + } + } + ); + } + + private void RegisterGetShiftDetailsTool(McpServer server) + { + const string toolName = "get_shift_details"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["shiftId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Shift ID" } + }, + new[] { "accessToken", "shiftId" } + ); + + server.AddTool( + toolName, + "Retrieves detailed information about a specific shift", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving shift details for {ShiftId}", args.ShiftId); + + var result = await _apiClient.GetAsync( + $"/api/v4/Shifts/GetShift?shiftId={args.ShiftId}", + args.AccessToken + ); + + return new { success = true, data = result }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving shift details"); + return CreateErrorResponse("Failed to retrieve shift details. Please try again later."); + } + } + ); + } + + private void RegisterGetCurrentShiftTool(McpServer server) + { + const string toolName = "get_current_shift"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Retrieves the current active shift for the user", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving current shift"); + + var result = await _apiClient.GetAsync( + "/api/v4/Shifts/GetCurrentShift", + args.AccessToken + ); + + return new { success = true, data = result }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving current shift"); + return CreateErrorResponse("Failed to retrieve current shift. Please try again later."); + } + } + ); + } + + private void RegisterSignupForShiftTool(McpServer server) + { + const string toolName = "signup_for_shift"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["shiftId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Shift ID" }, + ["shiftDayId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Shift day ID" } + }, + new[] { "accessToken", "shiftId", "shiftDayId" } + ); + + server.AddTool( + toolName, + "Signs up the user for a specific shift", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (args == null) + { + return CreateErrorResponse("Invalid arguments provided"); + } + + if (string.IsNullOrWhiteSpace(args.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + if (args.ShiftId <= 0) + { + return CreateErrorResponse("ShiftId must be greater than 0"); + } + + if (args.ShiftDayId <= 0) + { + return CreateErrorResponse("ShiftDayId must be greater than 0"); + } + + _logger.LogInformation("Signing up for shift {ShiftId}", args.ShiftId); + + var signupData = new + { + shiftId = args.ShiftId, + shiftDayId = args.ShiftDayId + }; + + var result = await _apiClient.PostAsync( + "/api/v4/Shifts/SignupForShift", + signupData, + args.AccessToken + ); + + return new { success = true, data = result, message = "Successfully signed up for shift" }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error signing up for shift"); + return CreateErrorResponse("Failed to sign up for shift. Please try again later."); + } + } + ); + } + + private static object CreateErrorResponse(string errorMessage) => + new { success = false, error = errorMessage }; + + private sealed class TokenArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + } + + private sealed class ShiftIdArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("shiftId")] + public int ShiftId { get; set; } + } + + private sealed class SignupShiftArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("shiftId")] + public int ShiftId { get; set; } + + [JsonProperty("shiftDayId")] + public int ShiftDayId { get; set; } + } + } +} + diff --git a/Web/Resgrid.Web.Mcp/Tools/UnitsToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/UnitsToolProvider.cs new file mode 100644 index 00000000..c0f4c3f9 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Tools/UnitsToolProvider.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using Newtonsoft.Json; + +namespace Resgrid.Web.Mcp.Tools +{ + /// + /// Provides MCP tools for managing units (vehicles/apparatus) in the Resgrid system + /// + public sealed class UnitsToolProvider + { + private readonly IApiClient _apiClient; + private readonly ILogger _logger; + private readonly List _toolNames; + + public UnitsToolProvider(IApiClient apiClient, ILogger logger) + { + _apiClient = apiClient; + _logger = logger; + _toolNames = new List(); + } + + public void RegisterTools(McpServer server) + { + RegisterGetUnitsTool(server); + RegisterGetUnitStatusTool(server); + RegisterSetUnitStatusTool(server); + RegisterGetUnitLocationTool(server); + } + + public IEnumerable GetToolNames() => _toolNames; + + private void RegisterGetUnitsTool(McpServer server) + { + const string toolName = "get_units"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Retrieves all units (vehicles/apparatus) in the user's department", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving units list"); + + var result = await _apiClient.GetAsync( + "/api/v4/Units/GetAll", + args.AccessToken + ); + + return new + { + success = true, + data = result + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving units"); + return CreateErrorResponse("Failed to retrieve units. Please try again later."); + } + } + ); + } + + private void RegisterGetUnitStatusTool(McpServer server) + { + const string toolName = "get_unit_statuses"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Retrieves the current status of all units in the department", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving unit statuses"); + + var result = await _apiClient.GetAsync( + "/api/v4/UnitStatus/GetAllStatuses", + args.AccessToken + ); + + return new + { + success = true, + data = result + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving unit statuses"); + return CreateErrorResponse("Failed to retrieve unit statuses. Please try again later."); + } + } + ); + } + + private void RegisterSetUnitStatusTool(McpServer server) + { + const string toolName = "set_unit_status"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["unitId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "The unique identifier of the unit" }, + ["statusType"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Status type code (1=Available, 2=Delayed, 3=Unavailable, 4=Committed, 5=Out Of Service, 6=Responding, 7=On Scene, 8=Staging, 9=Returning, 10=Cancelled, 11=Released, 12=Manual, 13=Enroute)" }, + ["note"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Optional note about the status change" } + }, + new[] { "accessToken", "unitId", "statusType" } + ); + + server.AddTool( + toolName, + "Sets the status for a unit (e.g., Available, Responding, On Scene, Out of Service)", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + if (args.UnitId <= 0) + { + return CreateErrorResponse("Valid unit ID is required"); + } + + if (args.StatusType < 1 || args.StatusType > 13) + { + return CreateErrorResponse("StatusType must be between 1 and 13 (1=Available, 2=Delayed, 3=Unavailable, 4=Committed, 5=Out Of Service, 6=Responding, 7=On Scene, 8=Staging, 9=Returning, 10=Cancelled, 11=Released, 12=Manual, 13=Enroute)"); + } + + _logger.LogInformation("Setting status for unit {UnitId}", args.UnitId); + + var statusData = new + { + unitId = args.UnitId, + type = args.StatusType, + note = args.Note + }; + + var result = await _apiClient.PostAsync( + "/api/v4/UnitStatus/SetUnitStatus", + statusData, + args.AccessToken + ); + + return new + { + success = true, + data = result, + message = "Unit status updated successfully" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error setting unit status"); + return CreateErrorResponse("Failed to set unit status. Please try again later."); + } + } + ); + } + + private void RegisterGetUnitLocationTool(McpServer server) + { + const string toolName = "get_unit_locations"; + _toolNames.Add(toolName); + + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + }, + new[] { "accessToken" } + ); + + server.AddTool( + toolName, + "Retrieves the current GPS locations of all units in the department", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.AccessToken)) + { + return CreateErrorResponse("Access token is required"); + } + + _logger.LogInformation("Retrieving unit locations"); + + var result = await _apiClient.GetAsync( + "/api/v4/UnitLocation/GetLatestUnitLocations", + args.AccessToken + ); + + return new + { + success = true, + data = result + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving unit locations"); + return CreateErrorResponse("Failed to retrieve unit locations. Please try again later."); + } + } + ); + } + + private static object CreateErrorResponse(string errorMessage) => + new { success = false, error = errorMessage }; + + private sealed class TokenArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + } + + private sealed class SetUnitStatusArgs + { + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [JsonProperty("unitId")] + public int UnitId { get; set; } + + [JsonProperty("statusType")] + public int StatusType { get; set; } + + [JsonProperty("note")] + public string Note { get; set; } + } + } +} + + + + + + + diff --git a/Web/Resgrid.Web.Mcp/appsettings.json b/Web/Resgrid.Web.Mcp/appsettings.json new file mode 100644 index 00000000..6c1307df --- /dev/null +++ b/Web/Resgrid.Web.Mcp/appsettings.json @@ -0,0 +1,22 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AppOptions": { + "ConfigPath": "" + }, + "McpServer": { + "ServerName": "Resgrid CAD System", + "ServerVersion": "1.0.0", + "ApiBaseUrl": "https://api.resgrid.com", + "Transport": "stdio" + }, + "ConnectionStrings": { + "ResgridContext": "" + } +} + diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj b/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj index 2acf3443..32e2f3c7 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj @@ -69,7 +69,7 @@ - + @@ -153,4 +153,4 @@ Resources.Designer.cs - \ No newline at end of file + diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index e45533eb..a3d02ce6 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -405,13 +405,6 @@ public async Task Settings(DepartmentSettingsModel model, IFormCo model.StaffingLevels = new SelectList(staffingLevels.GetActiveDetails(), "CustomStateDetailId", "ButtonText"); } - model.SuppressStaffingInfo = await _departmentSettingsService.GetDepartmentStaffingSuppressInfoAsync(DepartmentId, false); - - if (model.SuppressStaffingInfo != null) - { - model.EnableStaffingSupress = model.SuppressStaffingInfo.EnableSupressStaffing; - } - var actionLogs = await _customStateService.GetActivePersonnelStateForDepartmentAsync(DepartmentId); if (actionLogs == null) { @@ -631,6 +624,9 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, newAddre return RedirectToAction("Settings", "Department", new { Area = "User" }); } + // Load staffing suppression info for view rendering, but preserve the user's submitted EnableStaffingSupress value + model.SuppressStaffingInfo = await _departmentSettingsService.GetDepartmentStaffingSuppressInfoAsync(DepartmentId, false); + return View(model); } diff --git a/Web/Resgrid.Web/Resgrid.Web.csproj b/Web/Resgrid.Web/Resgrid.Web.csproj index 8d68fe99..b1730fb7 100644 --- a/Web/Resgrid.Web/Resgrid.Web.csproj +++ b/Web/Resgrid.Web/Resgrid.Web.csproj @@ -62,7 +62,7 @@ - + @@ -199,4 +199,4 @@ - \ No newline at end of file + diff --git a/Web/Resgrid.Web/wwwroot/js/ng/3rdpartylicenses.txt b/Web/Resgrid.Web/wwwroot/js/ng/3rdpartylicenses.txt index 76616fc3..5ec6ea05 100644 --- a/Web/Resgrid.Web/wwwroot/js/ng/3rdpartylicenses.txt +++ b/Web/Resgrid.Web/wwwroot/js/ng/3rdpartylicenses.txt @@ -5,7 +5,7 @@ MIT MIT The MIT License -Copyright (c) 2022 Google LLC. +Copyright (c) 2024 Google LLC. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -147,6 +147,31 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +angular-svg-icon +MIT +The MIT License (MIT) + +Copyright (c) 2023 David Czeck. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + calendar-utils MIT The MIT License (MIT) @@ -226,7 +251,7 @@ leaflet BSD-2-Clause BSD 2-Clause License -Copyright (c) 2010-2022, Vladimir Agafonkin +Copyright (c) 2010-2023, Volodymyr Agafonkin Copyright (c) 2010-2011, CloudMade All rights reserved. @@ -252,6 +277,185 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +livekit-client +Apache-2.0 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + lodash MIT Copyright OpenJS Foundation and other contributors @@ -534,26 +738,11 @@ Apache-2.0 -tslib -0BSD -Copyright (c) Microsoft Corporation. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. - zone.js MIT The MIT License -Copyright (c) 2010-2022 Google LLC. https://angular.io/license +Copyright (c) 2010-2024 Google LLC. https://angular.io/license Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal