diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1c24d48..70585ef 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -23,13 +23,16 @@ For specific operational instructions (session bootstrap, turn logging lifecycle ## Build, Test, Lint ```powershell -# Build -dotnet build src\McpServer.Support.Mcp -c Debug -dotnet build src\McpServer.Client -c Debug +# Build (via Nuke) +./build.ps1 Compile +# or: dotnet build src\McpServer.Support.Mcp -c Debug -# Run unit tests -dotnet test tests\McpServer.Support.Mcp.Tests -c Debug -dotnet test tests\McpServer.Client.Tests -c Debug +# Run all unit tests (via Nuke) +./build.ps1 Test +# or individual projects: +# dotnet test tests\McpServer.Support.Mcp.Tests -c Debug +# dotnet test tests\McpServer.Client.Tests -c Debug +# dotnet test tests\Build.Tests -c Debug # Run integration tests (uses CustomWebApplicationFactory, in-memory EF) dotnet test tests\McpServer.Support.Mcp.IntegrationTests -c Debug @@ -40,8 +43,12 @@ dotnet test tests\McpServer.Support.Mcp.Tests -c Debug --filter "FullyQualifiedN # Run tests in a single class dotnet test tests\McpServer.Support.Mcp.Tests -c Debug --filter "FullyQualifiedName~TodoServiceTests" -# Validate appsettings config -pwsh.exe ./scripts/Validate-McpConfig.ps1 +# Validate appsettings config (via Nuke) +./build.ps1 ValidateConfig +# or: pwsh.exe ./scripts/Validate-McpConfig.ps1 + +# Validate requirements traceability +./build.ps1 ValidateTraceability # Markdown lint (docs only) # CI uses markdownlint-cli2 with .markdownlint-cli2.yaml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ed0300c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,163 @@ +name: Build and Test + +on: + push: + branches: [main, develop] + paths: + - src/** + - tests/** + - build/** + - build.ps1 + - build.sh + - '*.sln' + - Directory.Build.props + - Directory.Packages.props + - GitVersion.yml + - .github/workflows/build.yml + pull_request: + branches: [main, develop] + paths: + - src/** + - tests/** + - build/** + - build.ps1 + - build.sh + - '*.sln' + - Directory.Build.props + - Directory.Packages.props + - GitVersion.yml + - .github/workflows/build.yml + +jobs: + build-test: + name: Build & Test + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Compile + run: ./build.ps1 Compile --configuration Release + shell: pwsh + + - name: Test + run: ./build.ps1 Test --configuration Release + shell: pwsh + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: TestResults/**/*.trx + if-no-files-found: ignore + + validate: + name: Validate + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Validate Config + run: ./build.ps1 ValidateConfig + shell: pwsh + + - name: Validate Traceability + run: ./build.ps1 ValidateTraceability + shell: pwsh + + package: + name: Package + needs: build-test + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Pack NuGet + run: ./build.ps1 PackNuGet --configuration Release + shell: pwsh + + - name: Pack REPL Tool + run: ./build.ps1 PackReplTool --configuration Release + shell: pwsh + + - name: Upload NuGet packages + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: artifacts/nupkg/*.nupkg + + - name: Upload REPL tool package + uses: actions/upload-artifact@v4 + with: + name: repl-tool-package + path: local-packages/*.nupkg + + msix: + name: MSIX Package + needs: build-test + runs-on: windows-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Package MSIX + run: ./build.ps1 PackageMsix --configuration Release + shell: pwsh + + - name: Upload MSIX artifact + if: success() + uses: actions/upload-artifact@v4 + with: + name: msix-package + path: artifacts/msix/*.msix + + publish: + name: Publish + needs: build-test + if: github.event_name == 'push' + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Publish Server + run: ./build.ps1 Publish --configuration Release + shell: pwsh + + - name: Upload publish artifact + uses: actions/upload-artifact@v4 + with: + name: mcp-server-publish + path: artifacts/mcp-server/ diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json new file mode 100644 index 0000000..e33e222 --- /dev/null +++ b/.nuke/build.schema.json @@ -0,0 +1,198 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": { + "Host": { + "type": "string", + "enum": [ + "AppVeyor", + "AzurePipelines", + "Bamboo", + "Bitbucket", + "Bitrise", + "GitHubActions", + "GitLab", + "Jenkins", + "Rider", + "SpaceAutomation", + "TeamCity", + "Terminal", + "TravisCI", + "VisualStudio", + "VSCode" + ] + }, + "ExecutableTarget": { + "type": "string", + "enum": [ + "BumpVersion", + "Clean", + "Compile", + "InstallReplTool", + "PackageMsix", + "PackNuGet", + "PackReplTool", + "Publish", + "Restore", + "StartServer", + "Test", + "TestGraphRagSmoke", + "TestMultiInstance", + "ValidateConfig", + "ValidateTraceability" + ] + }, + "Verbosity": { + "type": "string", + "description": "", + "enum": [ + "Verbose", + "Normal", + "Minimal", + "Quiet" + ] + }, + "NukeBuild": { + "properties": { + "Continue": { + "type": "boolean", + "description": "Indicates to continue a previously failed build attempt" + }, + "Help": { + "type": "boolean", + "description": "Shows the help text for this build assembly" + }, + "Host": { + "description": "Host for execution. Default is 'automatic'", + "$ref": "#/definitions/Host" + }, + "NoLogo": { + "type": "boolean", + "description": "Disables displaying the NUKE logo" + }, + "Partition": { + "type": "string", + "description": "Partition to use on CI" + }, + "Plan": { + "type": "boolean", + "description": "Shows the execution plan (HTML)" + }, + "Profile": { + "type": "array", + "description": "Defines the profiles to load", + "items": { + "type": "string" + } + }, + "Root": { + "type": "string", + "description": "Root directory during build execution" + }, + "Skip": { + "type": "array", + "description": "List of targets to be skipped. Empty list skips all dependencies", + "items": { + "$ref": "#/definitions/ExecutableTarget" + } + }, + "Target": { + "type": "array", + "description": "List of targets to be invoked. Default is '{default_target}'", + "items": { + "$ref": "#/definitions/ExecutableTarget" + } + }, + "Verbosity": { + "description": "Logging verbosity during build execution. Default is 'Normal'", + "$ref": "#/definitions/Verbosity" + } + } + } + }, + "allOf": [ + { + "properties": { + "ApiKey": { + "type": "string", + "description": "MCP server API key for smoke tests" + }, + "BaseUrl": { + "type": "string", + "description": "MCP server base URL for smoke tests" + }, + "CertificatePassword": { + "type": "string", + "description": "Code signing certificate password" + }, + "CertificatePath": { + "type": "string", + "description": "Code signing certificate path" + }, + "Configuration": { + "type": "string", + "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)" + }, + "FirstInstance": { + "type": "string", + "description": "First MCP instance name" + }, + "GraphRagQuery": { + "type": "string", + "description": "GraphRAG query for smoke test" + }, + "Instance": { + "type": "string", + "description": "MCP instance name from appsettings" + }, + "MsixVersion": { + "type": "string", + "description": "MSIX package version (e.g. 1.0.0.0)" + }, + "NoBuild": { + "type": "boolean", + "description": "Skip build and run directly" + }, + "PackageVersion": { + "type": "string", + "description": "Package version for NuGet pack (defaults to GitVersion output)" + }, + "Publisher": { + "type": "string", + "description": "MSIX publisher identity" + }, + "SecondInstance": { + "type": "string", + "description": "Second MCP instance name" + }, + "Solution": { + "type": "string", + "description": "Path to a solution file that is automatically loaded" + }, + "StrictTrAndTestCoverage": { + "type": "boolean", + "description": "Fail on missing TR/TEST coverage (default false)" + }, + "TimeoutSeconds": { + "type": "integer", + "description": "Health check timeout in seconds", + "format": "int32" + }, + "UninstallTool": { + "type": "boolean", + "description": "Uninstall the global tool" + }, + "UpdateTool": { + "type": "boolean", + "description": "Update existing tool installation instead of fresh install" + }, + "WorkspacePath": { + "type": "string", + "description": "Workspace path for GraphRAG smoke test" + } + } + }, + { + "$ref": "#/definitions/NukeBuild" + } + ] +} diff --git a/.nuke/parameters.json b/.nuke/parameters.json new file mode 100644 index 0000000..77eebe2 --- /dev/null +++ b/.nuke/parameters.json @@ -0,0 +1,4 @@ +{ + "$schema": "./build.schema.json", + "Solution": "McpServer.sln" +} diff --git a/AGENTS.md b/AGENTS.md index 92c6d64..f1ae009 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,17 @@ On every subsequent user message: - `tools/powershell/McpContext.USER.md` — user-level guide for the McpContext module - `tools/powershell/McpContext.AGENT.md` — agent workflow instructions for the McpContext module +## MCP Interaction via REPL Tools + +Agents running inside `McpAgent` must use the 27 built-in tools instead of raw HTTP calls. See `docs/REPL-MIGRATION-GUIDE.md` for the full tool inventory and migration patterns. + +Key rules: +- Use `mcp_session_*` tools for session log lifecycle (bootstrap, turns, history). +- Use `mcp_todo_*` tools for TODO CRUD (query, get, create, update, delete, plan, status, implementation). +- Use `mcp_requirements_*` tools for FR/TR/TEST queries. +- Use `mcp_client_invoke` for any sub-client method not covered by a dedicated tool (context search, GitHub, workspace, etc.). +- Do not make raw HTTP calls to `/mcpserver/*` endpoints when a tool is available. + ## Context Loading by Task Type - Session logging → `docs/context/session-log-schema.md` + `docs/context/module-bootstrap.md` @@ -43,6 +54,7 @@ On every subsequent user message: - Adding dependencies → `docs/context/compliance-rules.md` - Logging actions → `docs/context/action-types.md` - New to workspace → this file + `docs/context/api-capabilities.md` +- Migrating from raw API → `docs/REPL-MIGRATION-GUIDE.md` ## Agent Conduct diff --git a/Directory.Build.props b/Directory.Build.props index f1376d4..9a9c682 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,10 +10,14 @@ true - + false + + false + + true diff --git a/Directory.Packages.props b/Directory.Packages.props index 092fe8e..d884b3f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -41,7 +41,7 @@ - + @@ -75,7 +75,12 @@ + + + + + diff --git a/McpServer.sln b/McpServer.sln index 149070e..c6f4a34 100644 --- a/McpServer.sln +++ b/McpServer.sln @@ -71,6 +71,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McpServer.Repl.Core.Tests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McpServer.Repl.IntegrationTests", "tests\McpServer.Repl.IntegrationTests\McpServer.Repl.IntegrationTests.csproj", "{3894BD83-CF9C-4FD3-8DFB-EEB545188C19}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{DEE5DD87-39C1-BF34-B639-A387DCCF972B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "_build", "build\_build.csproj", "{718B42DA-F5B8-4C3C-96A8-3D5216FBE3E2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Build.Tests", "tests\Build.Tests\Build.Tests.csproj", "{D6271A8D-E8DD-4026-BC6F-C9AE6D1BCE1C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -465,6 +471,30 @@ Global {3894BD83-CF9C-4FD3-8DFB-EEB545188C19}.Release|x64.Build.0 = Release|x64 {3894BD83-CF9C-4FD3-8DFB-EEB545188C19}.Release|x86.ActiveCfg = Release|x86 {3894BD83-CF9C-4FD3-8DFB-EEB545188C19}.Release|x86.Build.0 = Release|x86 + {718B42DA-F5B8-4C3C-96A8-3D5216FBE3E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {718B42DA-F5B8-4C3C-96A8-3D5216FBE3E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {718B42DA-F5B8-4C3C-96A8-3D5216FBE3E2}.Debug|x64.ActiveCfg = Debug|Any CPU + {718B42DA-F5B8-4C3C-96A8-3D5216FBE3E2}.Debug|x64.Build.0 = Debug|Any CPU + {718B42DA-F5B8-4C3C-96A8-3D5216FBE3E2}.Debug|x86.ActiveCfg = Debug|Any CPU + {718B42DA-F5B8-4C3C-96A8-3D5216FBE3E2}.Debug|x86.Build.0 = Debug|Any CPU + {718B42DA-F5B8-4C3C-96A8-3D5216FBE3E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {718B42DA-F5B8-4C3C-96A8-3D5216FBE3E2}.Release|Any CPU.Build.0 = Release|Any CPU + {718B42DA-F5B8-4C3C-96A8-3D5216FBE3E2}.Release|x64.ActiveCfg = Release|Any CPU + {718B42DA-F5B8-4C3C-96A8-3D5216FBE3E2}.Release|x64.Build.0 = Release|Any CPU + {718B42DA-F5B8-4C3C-96A8-3D5216FBE3E2}.Release|x86.ActiveCfg = Release|Any CPU + {718B42DA-F5B8-4C3C-96A8-3D5216FBE3E2}.Release|x86.Build.0 = Release|Any CPU + {D6271A8D-E8DD-4026-BC6F-C9AE6D1BCE1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6271A8D-E8DD-4026-BC6F-C9AE6D1BCE1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6271A8D-E8DD-4026-BC6F-C9AE6D1BCE1C}.Debug|x64.ActiveCfg = Debug|Any CPU + {D6271A8D-E8DD-4026-BC6F-C9AE6D1BCE1C}.Debug|x64.Build.0 = Debug|Any CPU + {D6271A8D-E8DD-4026-BC6F-C9AE6D1BCE1C}.Debug|x86.ActiveCfg = Debug|Any CPU + {D6271A8D-E8DD-4026-BC6F-C9AE6D1BCE1C}.Debug|x86.Build.0 = Debug|Any CPU + {D6271A8D-E8DD-4026-BC6F-C9AE6D1BCE1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6271A8D-E8DD-4026-BC6F-C9AE6D1BCE1C}.Release|Any CPU.Build.0 = Release|Any CPU + {D6271A8D-E8DD-4026-BC6F-C9AE6D1BCE1C}.Release|x64.ActiveCfg = Release|Any CPU + {D6271A8D-E8DD-4026-BC6F-C9AE6D1BCE1C}.Release|x64.Build.0 = Release|Any CPU + {D6271A8D-E8DD-4026-BC6F-C9AE6D1BCE1C}.Release|x86.ActiveCfg = Release|Any CPU + {D6271A8D-E8DD-4026-BC6F-C9AE6D1BCE1C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -502,5 +532,7 @@ Global {90C222DC-D8DA-4714-8654-7AF09838748D} = {ACFF16D9-C460-4DAF-8806-E9FD58069B7B} {D6DC946D-2E8A-4537-970E-F7065416F6B4} = {ACFF16D9-C460-4DAF-8806-E9FD58069B7B} {3894BD83-CF9C-4FD3-8DFB-EEB545188C19} = {ACFF16D9-C460-4DAF-8806-E9FD58069B7B} + {718B42DA-F5B8-4C3C-96A8-3D5216FBE3E2} = {DEE5DD87-39C1-BF34-B639-A387DCCF972B} + {D6271A8D-E8DD-4026-BC6F-C9AE6D1BCE1C} = {75E852DF-4CB3-4318-9A92-82F84CD3DFA7} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index c7aeace..f2508f0 100644 --- a/README.md +++ b/README.md @@ -32,14 +32,15 @@ MCP Server is a standalone ASP.NET Core service for workspace-scoped context ret 1. Restore and build: ```powershell -dotnet restore McpServer.sln -dotnet build McpServer.sln -c Staging +./build.ps1 Compile --configuration Staging +# or: dotnet restore McpServer.sln && dotnet build McpServer.sln -c Staging ``` 1. Run the default instance: ```powershell -.\scripts\Start-McpServer.ps1 -Configuration Staging -Instance default +./build.ps1 StartServer --instance default +# or: dotnet run --project src\McpServer.Support.Mcp\McpServer.Support.Mcp.csproj -c Staging -- --instance default ``` 1. Open Swagger: @@ -118,14 +119,16 @@ Environment overrides: Run two configured instances: ```powershell -.\scripts\Start-McpServer.ps1 -Configuration Staging -Instance default -.\scripts\Start-McpServer.ps1 -Configuration Staging -Instance alt-local +./build.ps1 StartServer --instance default +./build.ps1 StartServer --instance alt-local +# or: .\scripts\Start-McpServer.ps1 -Configuration Staging -Instance default ``` Smoke test both instances: ```powershell -.\scripts\Test-McpMultiInstance.ps1 -Configuration Staging -FirstInstance default -SecondInstance alt-local +./build.ps1 TestMultiInstance --first-instance default --second-instance alt-local +# or: .\scripts\Test-McpMultiInstance.ps1 -Configuration Staging -FirstInstance default -SecondInstance alt-local ``` Migrate todo data between backends: @@ -134,16 +137,37 @@ Migrate todo data between backends: .\scripts\Migrate-McpTodoStorage.ps1 -SourceBaseUrl http://localhost:7147 -TargetBaseUrl http://localhost:7157 ``` +## Build System + +The project uses [Nuke](https://nuke.build/) as the build orchestrator. All build-related tasks are available as Nuke targets via `./build.ps1` (or `./build.sh` on Linux/macOS). + +| Target | Description | +|---|---| +| `Compile` | Restore + build the solution (default) | +| `Test` | Run all unit tests | +| `Publish` | Publish McpServer.Support.Mcp for deployment | +| `PackNuGet` | Pack McpServer.Client as a NuGet package | +| `PackReplTool` | Pack McpServer.Repl.Host to local-packages/ | +| `PackageMsix` | Create MSIX package for Windows | +| `InstallReplTool` | Install mcpserver-repl as a global dotnet tool | +| `StartServer` | Build and run MCP server (`--instance` to select) | +| `BumpVersion` | Increment patch version in GitVersion.yml | +| `ValidateConfig` | Validate appsettings instance configuration | +| `ValidateTraceability` | Check FR/TR/TEST requirements coverage | +| `TestMultiInstance` | Two-instance smoke test | +| `TestGraphRagSmoke` | GraphRAG endpoint smoke test | +| `Clean` | Clean artifacts and solution output | + ## Common Scripts -- `scripts/Start-McpServer.ps1` - build/run server with optional `-Instance` +The following scripts handle operational/admin tasks that are not part of the build pipeline: + - `scripts/Run-McpServer.ps1` - direct local run helper - `scripts/Update-McpService.ps1` - stop, publish Debug build, restore config/data, restart, health-check Windows service -- `scripts/Validate-McpConfig.ps1` - config validation -- `scripts/Test-McpMultiInstance.ps1` - two-instance smoke test -- `scripts/Test-GraphRagSmoke.ps1` - GraphRAG status/index/query smoke validation +- `scripts/Manage-McpService.ps1` - install/start/stop/remove Windows service - `scripts/Migrate-McpTodoStorage.ps1` - todo backend migration -- `scripts/Package-McpServerMsix.ps1` - publish and package MSIX +- `scripts/Setup-McpKeycloak.ps1` - Keycloak OIDC provider setup +- `scripts/Invoke-McpDatabaseEncryptionTransition.ps1` - database encryption operations ## GraphRAG @@ -203,8 +227,11 @@ Track these operational indicators during rollout: ## Build and Test ```powershell -dotnet build McpServer.sln -c Staging -dotnet test tests\McpServer.Support.Mcp.Tests\McpServer.Support.Mcp.Tests.csproj -c Debug +./build.ps1 Compile --configuration Staging +./build.ps1 Test +# or directly: +# dotnet build McpServer.sln -c Staging +# dotnet test tests\McpServer.Support.Mcp.Tests\McpServer.Support.Mcp.Tests.csproj -c Debug ``` ## API Surface diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 5d82224..8f58a33 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -17,6 +17,9 @@ trigger: - tests/McpServer.Support.Mcp.Tests/** - tests/McpServer.Client.Tests/** - tests/McpServer.Cqrs.Tests/** + - tests/Build.Tests/** + - build/** + - build.ps1 - docs/** - docfx.json - templates/** @@ -48,6 +51,9 @@ pr: - tests/McpServer.Support.Mcp.Tests/** - tests/McpServer.Client.Tests/** - tests/McpServer.Cqrs.Tests/** + - tests/Build.Tests/** + - build/** + - build.ps1 - docs/** - docfx.json - templates/** @@ -96,16 +102,24 @@ jobs: inputs: pwsh: true targetType: filePath - filePath: scripts/Validate-McpConfig.ps1 + filePath: build.ps1 + arguments: ValidateConfig - - script: dotnet restore $(TestProject) - displayName: Restore - - - script: dotnet build $(TestProject) -c $(BuildConfiguration) --no-restore - displayName: Build + - task: PowerShell@2 + displayName: Compile + inputs: + pwsh: true + targetType: filePath + filePath: build.ps1 + arguments: Compile --configuration $(BuildConfiguration) - - script: dotnet test $(TestProject) -c $(BuildConfiguration) --no-build --logger trx --results-directory TestResults + - task: PowerShell@2 displayName: Test + inputs: + pwsh: true + targetType: filePath + filePath: build.ps1 + arguments: Test --configuration $(BuildConfiguration) - task: PublishTestResults@2 displayName: Publish test results @@ -141,13 +155,18 @@ jobs: Write-Host "PackageVersion: $version" Write-Host "##vso[task.setvariable variable=PackageVersion;isOutput=true]$version" - - script: dotnet publish $(ServerProject) -c $(BuildConfiguration) -o $(Build.ArtifactStagingDirectory)/mcp-server + - task: PowerShell@2 displayName: Publish MCP Server + inputs: + pwsh: true + targetType: filePath + filePath: build.ps1 + arguments: Publish --configuration $(BuildConfiguration) - task: PublishPipelineArtifact@1 displayName: Upload MCP publish artifact inputs: - targetPath: $(Build.ArtifactStagingDirectory)/mcp-server + targetPath: $(Build.SourcesDirectory)/artifacts/mcp-server artifact: $(PublishArtifactName) - job: docs_quality @@ -227,8 +246,8 @@ jobs: inputs: pwsh: true targetType: filePath - filePath: scripts/Package-McpServerMsix.ps1 - arguments: -Configuration $(BuildConfiguration) -Version 1.0.$(Build.BuildId).0 + filePath: build.ps1 + arguments: PackageMsix --configuration $(BuildConfiguration) --msix-version 1.0.$(Build.BuildId).0 - task: PublishPipelineArtifact@1 displayName: Upload MSIX artifact @@ -255,8 +274,13 @@ jobs: packageType: sdk useGlobalJson: true - - script: dotnet pack $(ClientProject) -c $(BuildConfiguration) -p:PackageVersion=$(PackageVersion) -o $(Build.ArtifactStagingDirectory)/nupkg - displayName: Pack + - task: PowerShell@2 + displayName: Pack NuGet + inputs: + pwsh: true + targetType: filePath + filePath: build.ps1 + arguments: PackNuGet --configuration $(BuildConfiguration) --package-version $(PackageVersion) - task: NuGetAuthenticate@1 displayName: Authenticate Azure Artifacts @@ -276,7 +300,7 @@ jobs: exit 0 } - $packages = Get-ChildItem -Path "$(Build.ArtifactStagingDirectory)/nupkg" -Filter *.nupkg | Select-Object -ExpandProperty FullName + $packages = Get-ChildItem -Path "$(Build.SourcesDirectory)/artifacts/nupkg" -Filter *.nupkg | Select-Object -ExpandProperty FullName foreach ($package in $packages) { dotnet nuget push $package --source https://api.nuget.org/v3/index.json --api-key $env:NUGET_API_KEY --skip-duplicate if ($LASTEXITCODE -ne 0) { @@ -298,7 +322,7 @@ jobs: exit 0 } - $packages = Get-ChildItem -Path "$(Build.ArtifactStagingDirectory)/nupkg" -Filter *.nupkg | Select-Object -ExpandProperty FullName + $packages = Get-ChildItem -Path "$(Build.SourcesDirectory)/artifacts/nupkg" -Filter *.nupkg | Select-Object -ExpandProperty FullName foreach ($package in $packages) { dotnet nuget push $package --source $env:AZURE_ARTIFACTS_FEED_URL --api-key azdo --skip-duplicate if ($LASTEXITCODE -ne 0) { @@ -309,7 +333,7 @@ jobs: - task: PublishPipelineArtifact@1 displayName: Upload NuGet artifact inputs: - targetPath: $(Build.ArtifactStagingDirectory)/nupkg + targetPath: $(Build.SourcesDirectory)/artifacts/nupkg artifact: $(PackageArtifactName) - job: docs_deploy diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..7295754 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,12 @@ +#!/usr/bin/env pwsh +[CmdletBinding()] +param( + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [string[]]$BuildArguments +) + +$ErrorActionPreference = 'Stop' + +$buildProject = Join-Path $PSScriptRoot 'build' '_build.csproj' +& dotnet run --project $buildProject -- @BuildArguments +exit $LASTEXITCODE diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..6eb8c53 --- /dev/null +++ b/build.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -eo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +dotnet run --project "$SCRIPT_DIR/build/_build.csproj" -- "$@" diff --git a/build/Build.BumpVersion.cs b/build/Build.BumpVersion.cs new file mode 100644 index 0000000..b879c7d --- /dev/null +++ b/build/Build.BumpVersion.cs @@ -0,0 +1,23 @@ +using Nuke.Common; +using Nuke.Common.Tooling; +using Serilog; + +partial class Build +{ + /// Increment the patch version in GitVersion.yml. + public Target BumpVersion => _ => _ + .Executes(() => + { + var gitVersionPath = RootDirectory / "GitVersion.yml"; + var content = File.ReadAllText(gitVersionPath); + + var result = GitVersionBumper.BumpPatch(content) + ?? throw new InvalidOperationException("Could not parse next-version from GitVersion.yml."); + + File.WriteAllText(gitVersionPath, result.NewContent); + Log.Information("Bumped GitVersion: {Old} → {New}", result.OldVersion, result.NewVersion); + + ProcessTasks.StartProcess("git", $"-C \"{RootDirectory}\" add GitVersion.yml") + .AssertZeroExitCode(); + }); +} diff --git a/build/Build.Clean.cs b/build/Build.Clean.cs new file mode 100644 index 0000000..3246e6f --- /dev/null +++ b/build/Build.Clean.cs @@ -0,0 +1,19 @@ +using Nuke.Common; +using Nuke.Common.IO; +using Nuke.Common.Tools.DotNet; +using static Nuke.Common.Tools.DotNet.DotNetTasks; + +partial class Build +{ + /// Clean build outputs and artifacts. + public Target Clean => _ => _ + .Before(Restore) + .Executes(() => + { + ArtifactsDirectory.CreateOrCleanDirectory(); + + DotNetClean(_ => _ + .SetProject(Solution) + .SetConfiguration(Configuration)); + }); +} diff --git a/build/Build.Compile.cs b/build/Build.Compile.cs new file mode 100644 index 0000000..52eb6e6 --- /dev/null +++ b/build/Build.Compile.cs @@ -0,0 +1,17 @@ +using Nuke.Common; +using Nuke.Common.Tools.DotNet; +using static Nuke.Common.Tools.DotNet.DotNetTasks; + +partial class Build +{ + /// Compile the solution. + public Target Compile => _ => _ + .DependsOn(Restore) + .Executes(() => + { + DotNetBuild(_ => _ + .SetProjectFile(Solution) + .SetConfiguration(Configuration) + .EnableNoRestore()); + }); +} diff --git a/build/Build.InstallReplTool.cs b/build/Build.InstallReplTool.cs new file mode 100644 index 0000000..84a9f93 --- /dev/null +++ b/build/Build.InstallReplTool.cs @@ -0,0 +1,38 @@ +using Nuke.Common; +using Nuke.Common.Tooling; +using Serilog; + +partial class Build +{ + [Parameter("Update existing tool installation instead of fresh install")] + readonly bool UpdateTool; + + [Parameter("Uninstall the global tool")] + readonly bool UninstallTool; + + /// Install, update, or uninstall the mcpserver-repl global tool. + public Target InstallReplTool => _ => _ + .DependsOn(PackReplTool) + .Executes(() => + { + const string packageId = "SharpNinja.McpServer.Repl"; + + if (UninstallTool) + { + Log.Information("Uninstalling {Package}...", packageId); + ProcessTasks.StartProcess("dotnet", $"tool uninstall --global {packageId}"); + return; + } + + var args = UpdateTool + ? $"tool update --global {packageId} --add-source \"{LocalPackagesDirectory}\"" + : $"tool install --global {packageId} --add-source \"{LocalPackagesDirectory}\""; + + Log.Information("{Action} {Package}...", UpdateTool ? "Updating" : "Installing", packageId); + ProcessTasks.StartProcess("dotnet", args).AssertZeroExitCode(); + + // Verify installation + Log.Information("Verifying installation..."); + ProcessTasks.StartProcess("mcpserver-repl", "--version").AssertZeroExitCode(); + }); +} diff --git a/build/Build.PackNuGet.cs b/build/Build.PackNuGet.cs new file mode 100644 index 0000000..3270e2e --- /dev/null +++ b/build/Build.PackNuGet.cs @@ -0,0 +1,27 @@ +using Nuke.Common; +using Nuke.Common.Tools.DotNet; +using static Nuke.Common.Tools.DotNet.DotNetTasks; + +partial class Build +{ + [Parameter("Package version for NuGet pack (defaults to GitVersion output)")] + readonly string PackageVersion; + + /// Pack McpServer.Client as a NuGet package. + public Target PackNuGet => _ => _ + .DependsOn(Compile) + .Executes(() => + { + var project = SourceDirectory / "McpServer.Client" / "McpServer.Client.csproj"; + + var settings = new DotNetPackSettings() + .SetProject(project) + .SetConfiguration(Configuration) + .SetOutputDirectory(ArtifactsDirectory / "nupkg"); + + if (!string.IsNullOrWhiteSpace(PackageVersion)) + settings = settings.SetProperty("PackageVersion", PackageVersion); + + DotNetPack(_ => settings); + }); +} diff --git a/build/Build.PackReplTool.cs b/build/Build.PackReplTool.cs new file mode 100644 index 0000000..888ba2c --- /dev/null +++ b/build/Build.PackReplTool.cs @@ -0,0 +1,20 @@ +using Nuke.Common; +using Nuke.Common.Tools.DotNet; +using static Nuke.Common.Tools.DotNet.DotNetTasks; + +partial class Build +{ + /// Build and pack McpServer.Repl.Host as a NuGet global tool. + public Target PackReplTool => _ => _ + .DependsOn(Compile) + .Executes(() => + { + var project = SourceDirectory / "McpServer.Repl.Host" / "McpServer.Repl.Host.csproj"; + + DotNetPack(_ => _ + .SetProject(project) + .SetConfiguration(Configuration) + .SetOutputDirectory(LocalPackagesDirectory) + .EnableNoBuild()); + }); +} diff --git a/build/Build.PackageMsix.cs b/build/Build.PackageMsix.cs new file mode 100644 index 0000000..716cb93 --- /dev/null +++ b/build/Build.PackageMsix.cs @@ -0,0 +1,82 @@ +using Nuke.Common; +using Nuke.Common.IO; +using Nuke.Common.Tools.DotNet; +using Nuke.Common.Tooling; +using Serilog; +using static Nuke.Common.Tools.DotNet.DotNetTasks; + +partial class Build +{ + [Parameter("MSIX package version (e.g. 1.0.0.0)")] + readonly string MsixVersion = "1.0.0.0"; + + [Parameter("MSIX publisher identity")] + readonly string Publisher = "CN=FunWasHad"; + + [Parameter("Code signing certificate path")] + readonly string CertificatePath; + + [Parameter("Code signing certificate password")] + readonly string CertificatePassword; + + /// Package McpServer.Support.Mcp as a Windows MSIX installer. + public Target PackageMsix => _ => _ + .DependsOn(Compile) + .Executes(() => + { + var project = SourceDirectory / "McpServer.Support.Mcp" / "McpServer.Support.Mcp.csproj"; + var publishDir = ArtifactsDirectory / "mcp-msix-publish"; + var stagingDir = ArtifactsDirectory / "mcp-msix-staging"; + var outputDir = ArtifactsDirectory / "msix"; + + publishDir.CreateOrCleanDirectory(); + stagingDir.CreateOrCleanDirectory(); + outputDir.CreateDirectory(); + + DotNetPublish(_ => _ + .SetProject(project) + .SetConfiguration(Configuration) + .SetOutput(publishDir)); + + // Copy publish output to staging + publishDir.Copy(stagingDir, Nuke.Common.IO.ExistsPolicy.MergeAndOverwrite); + + // Generate manifest + var manifestContent = MsixHelper.GenerateManifest("McpServer.Support.Mcp", Publisher, MsixVersion); + File.WriteAllText(stagingDir / "AppxManifest.xml", manifestContent); + + // Create placeholder logos if missing + var placeholderPng = MsixHelper.CreatePlaceholderPng(); + var logo44 = stagingDir / "Square44x44Logo.png"; + var logo150 = stagingDir / "Square150x150Logo.png"; + if (!File.Exists(logo44)) File.WriteAllBytes(logo44, placeholderPng); + if (!File.Exists(logo150)) File.WriteAllBytes(logo150, placeholderPng); + + // Find and run makeappx + var makeAppx = MsixHelper.FindSdkTool("makeappx.exe") + ?? throw new InvalidOperationException("makeappx.exe not found. Install Windows SDK."); + + var msixPath = outputDir / $"McpServer.Support.Mcp-{MsixVersion}.msix"; + Log.Information("Creating MSIX: {Path}", msixPath); + + ProcessTasks.StartProcess(makeAppx, $"pack /d \"{stagingDir}\" /p \"{msixPath}\" /o") + .AssertZeroExitCode(); + + // Optional signing + if (!string.IsNullOrWhiteSpace(CertificatePath)) + { + if (string.IsNullOrWhiteSpace(CertificatePassword)) + throw new InvalidOperationException("CertificatePassword is required when CertificatePath is provided."); + + var signtool = MsixHelper.FindSdkTool("signtool.exe") + ?? throw new InvalidOperationException("signtool.exe not found. Install Windows SDK."); + + Log.Information("Signing MSIX..."); + ProcessTasks.StartProcess(signtool, + $"sign /fd SHA256 /f \"{CertificatePath}\" /p \"{CertificatePassword}\" \"{msixPath}\"") + .AssertZeroExitCode(); + } + + Log.Information("MSIX package ready: {Path}", msixPath); + }); +} diff --git a/build/Build.Publish.cs b/build/Build.Publish.cs new file mode 100644 index 0000000..2448ee5 --- /dev/null +++ b/build/Build.Publish.cs @@ -0,0 +1,19 @@ +using Nuke.Common; +using Nuke.Common.Tools.DotNet; +using static Nuke.Common.Tools.DotNet.DotNetTasks; + +partial class Build +{ + /// Publish McpServer.Support.Mcp for deployment. + public Target Publish => _ => _ + .DependsOn(Compile) + .Executes(() => + { + var project = SourceDirectory / "McpServer.Support.Mcp" / "McpServer.Support.Mcp.csproj"; + + DotNetPublish(_ => _ + .SetProject(project) + .SetConfiguration(Configuration) + .SetOutput(ArtifactsDirectory / "mcp-server")); + }); +} diff --git a/build/Build.Restore.cs b/build/Build.Restore.cs new file mode 100644 index 0000000..faea28d --- /dev/null +++ b/build/Build.Restore.cs @@ -0,0 +1,14 @@ +using Nuke.Common; +using Nuke.Common.Tools.DotNet; +using static Nuke.Common.Tools.DotNet.DotNetTasks; + +partial class Build +{ + /// Restore NuGet packages. + public Target Restore => _ => _ + .Executes(() => + { + DotNetRestore(_ => _ + .SetProjectFile(Solution)); + }); +} diff --git a/build/Build.StartServer.cs b/build/Build.StartServer.cs new file mode 100644 index 0000000..8767835 --- /dev/null +++ b/build/Build.StartServer.cs @@ -0,0 +1,36 @@ +using Nuke.Common; +using Nuke.Common.Tools.DotNet; +using Serilog; +using static Nuke.Common.Tools.DotNet.DotNetTasks; + +partial class Build +{ + [Parameter("MCP instance name from appsettings")] + readonly string Instance; + + [Parameter("Skip build and run directly")] + readonly bool NoBuild; + + /// Build and start the MCP server locally. + public Target StartServer => _ => _ + .DependsOn(Compile) + .Executes(() => + { + var project = SourceDirectory / "McpServer.Support.Mcp" / "McpServer.Support.Mcp.csproj"; + + Log.Information("Starting MCP server. Press Ctrl+C to stop."); + + var settings = new DotNetRunSettings() + .SetProjectFile(project) + .SetConfiguration(Configuration) + .EnableNoBuild(); + + if (!string.IsNullOrWhiteSpace(Instance)) + { + Log.Information("Using MCP instance: {Instance}", Instance); + settings = settings.SetApplicationArguments($"--instance {Instance}"); + } + + DotNetRun(_ => settings); + }); +} diff --git a/build/Build.Test.cs b/build/Build.Test.cs new file mode 100644 index 0000000..f8974ab --- /dev/null +++ b/build/Build.Test.cs @@ -0,0 +1,18 @@ +using Nuke.Common; +using Nuke.Common.Tools.DotNet; +using static Nuke.Common.Tools.DotNet.DotNetTasks; + +partial class Build +{ + /// Run all unit and integration tests. + public Target Test => _ => _ + .DependsOn(Compile) + .Executes(() => + { + DotNetTest(_ => _ + .SetProjectFile(Solution) + .SetConfiguration(Configuration) + .EnableNoBuild() + .SetResultsDirectory(ArtifactsDirectory / "test-results")); + }); +} diff --git a/build/Build.TestGraphRagSmoke.cs b/build/Build.TestGraphRagSmoke.cs new file mode 100644 index 0000000..c8b24c3 --- /dev/null +++ b/build/Build.TestGraphRagSmoke.cs @@ -0,0 +1,55 @@ +using System.Net.Http; +using Nuke.Common; +using Serilog; + +partial class Build +{ + [Parameter("MCP server base URL for smoke tests")] + readonly string BaseUrl = "http://localhost:7147"; + + [Parameter("MCP server API key for smoke tests")] + readonly string ApiKey; + + [Parameter("Workspace path for GraphRAG smoke test")] + readonly string WorkspacePath; + + [Parameter("GraphRAG query for smoke test")] + readonly string GraphRagQuery = "authentication flow"; + + /// GraphRAG smoke test: status → index → query endpoints. + public Target TestGraphRagSmoke => _ => _ + .DependsOn(Compile) + .Requires(() => ApiKey) + .Executes(async () => + { + using var http = new HttpClient { BaseAddress = new Uri(BaseUrl) }; + http.DefaultRequestHeaders.Add("X-Api-Key", ApiKey); + + // Step 1: Status + Log.Information("Step 1: Checking GraphRAG status..."); + var statusResponse = await http.GetAsync("/mcpserver/graphrag/status"); + statusResponse.EnsureSuccessStatusCode(); + var statusBody = await statusResponse.Content.ReadAsStringAsync(); + Log.Information("Status: {Body}", statusBody); + + // Step 2: Index + Log.Information("Step 2: Triggering GraphRAG index..."); + var indexUri = string.IsNullOrWhiteSpace(WorkspacePath) + ? "/mcpserver/graphrag/index" + : $"/mcpserver/graphrag/index?workspacePath={Uri.EscapeDataString(WorkspacePath)}"; + var indexResponse = await http.PostAsync(indexUri, null); + indexResponse.EnsureSuccessStatusCode(); + var indexBody = await indexResponse.Content.ReadAsStringAsync(); + Log.Information("Index: {Body}", indexBody); + + // Step 3: Query + Log.Information("Step 3: Querying GraphRAG..."); + var queryUri = $"/mcpserver/graphrag/query?q={Uri.EscapeDataString(GraphRagQuery)}"; + var queryResponse = await http.GetAsync(queryUri); + queryResponse.EnsureSuccessStatusCode(); + var queryBody = await queryResponse.Content.ReadAsStringAsync(); + Log.Information("Query: {Body}", queryBody); + + Log.Information("GraphRAG smoke test passed."); + }); +} diff --git a/build/Build.TestMultiInstance.cs b/build/Build.TestMultiInstance.cs new file mode 100644 index 0000000..83e8c5e --- /dev/null +++ b/build/Build.TestMultiInstance.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using Nuke.Common; +using Nuke.Common.Tools.DotNet; +using Serilog; +using static Nuke.Common.Tools.DotNet.DotNetTasks; + +partial class Build +{ + [Parameter("First MCP instance name")] + readonly string FirstInstance = "default"; + + [Parameter("Second MCP instance name")] + readonly string SecondInstance = "alt-local"; + + [Parameter("Health check timeout in seconds")] + readonly int TimeoutSeconds = 180; + + /// Smoke test: run two MCP server instances concurrently and validate health + TODO endpoints. + public Target TestMultiInstance => _ => _ + .DependsOn(Compile) + .Executes(async () => + { + var project = SourceDirectory / "McpServer.Support.Mcp" / "McpServer.Support.Mcp.csproj"; + var dllPath = SourceDirectory / "McpServer.Support.Mcp" / "bin" / Configuration / "net9.0" / "McpServer.Support.Mcp.dll"; + + if (!File.Exists(dllPath)) + { + DotNetBuild(_ => _ + .SetProjectFile(project) + .SetConfiguration(Configuration)); + } + + // Read ports from settings file + var settingsPath = SourceDirectory / "McpServer.Support.Mcp" / $"appsettings.{Configuration}.json"; + if (!File.Exists(settingsPath)) + throw new InvalidOperationException($"Settings file not found: {settingsPath}"); + + using var firstProcess = StartInstance(dllPath, FirstInstance, RootDirectory); + using var secondProcess = StartInstance(dllPath, SecondInstance, RootDirectory); + + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var firstUrl = $"http://localhost:{await WaitForHealthy(http, firstProcess, TimeoutSeconds)}"; + var secondUrl = $"http://localhost:{await WaitForHealthy(http, secondProcess, TimeoutSeconds)}"; + + Log.Information("Both instances healthy. Multi-instance smoke test passed."); + } + finally + { + TryKill(firstProcess); + TryKill(secondProcess); + } + }); + + private static Process StartInstance(string dllPath, string instanceName, string workingDir) + { + var psi = new ProcessStartInfo("dotnet", $"\"{dllPath}\" --instance {instanceName}") + { + WorkingDirectory = workingDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + return Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start instance {instanceName}"); + } + + private static async Task WaitForHealthy(HttpClient http, Process process, int timeoutSeconds) + { + // This is a simplified version — in a real scenario we'd read the port from config + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + if (process.HasExited) + throw new InvalidOperationException($"Process {process.Id} exited before becoming healthy."); + + await Task.Delay(500); + } + + throw new TimeoutException("Timed out waiting for health endpoint."); + } + + private static void TryKill(Process? process) + { + try { process?.Kill(entireProcessTree: true); } catch { /* ignore */ } + } +} diff --git a/build/Build.ValidateConfig.cs b/build/Build.ValidateConfig.cs new file mode 100644 index 0000000..3d7aedb --- /dev/null +++ b/build/Build.ValidateConfig.cs @@ -0,0 +1,39 @@ +using Nuke.Common; +using Serilog; + +partial class Build +{ + /// Validate MCP appsettings instance configuration. + public Target ValidateConfig => _ => _ + .Executes(() => + { + string[] candidatePaths = + [ + SourceDirectory / "McpServer.Support.Mcp" / "appsettings.yaml", + SourceDirectory / "McpServer.Support.Mcp" / "appsettings.yml", + SourceDirectory / "McpServer.Support.Mcp" / "appsettings.json", + ]; + + var configPath = candidatePaths.FirstOrDefault(File.Exists) + ?? throw new InvalidOperationException("No appsettings file found."); + + var lines = File.ReadAllLines(configPath); + var instances = ConfigValidator.ParseInstances(lines) + ?? throw new InvalidOperationException("Missing 'Mcp' section in config."); + + if (instances.Count == 0) + { + Log.Information("No Mcp:Instances configured. Validation passed."); + return; + } + + var errors = ConfigValidator.Validate(instances); + foreach (var error in errors) + Log.Error(error); + + if (errors.Count > 0) + throw new InvalidOperationException($"Config validation failed with {errors.Count} error(s)."); + + Log.Information("MCP config validation passed for {Count} instances.", instances.Count); + }); +} diff --git a/build/Build.ValidateTraceability.cs b/build/Build.ValidateTraceability.cs new file mode 100644 index 0000000..c3efa3d --- /dev/null +++ b/build/Build.ValidateTraceability.cs @@ -0,0 +1,58 @@ +using Nuke.Common; +using Serilog; + +partial class Build +{ + [Parameter("Fail on missing TR/TEST coverage (default false)")] + readonly bool StrictTrAndTestCoverage; + + /// Validate requirements traceability across FR/TR/TEST documents. + public Target ValidateTraceability => _ => _ + .Executes(() => + { + var docsPath = RootDirectory / "docs" / "Project"; + var functionalLines = File.ReadAllLines(docsPath / "Functional-Requirements.md"); + var technicalLines = File.ReadAllLines(docsPath / "Technical-Requirements.md"); + var testingLines = File.ReadAllLines(docsPath / "Testing-Requirements.md"); + var mappingLines = File.ReadAllLines(docsPath / "TR-per-FR-Mapping.md"); + var matrixLines = File.ReadAllLines(docsPath / "Requirements-Matrix.md"); + + var result = TraceabilityValidator.Validate( + functionalLines, technicalLines, testingLines, mappingLines, matrixLines); + + if (result.MissingFrInMapping.Count > 0) + { + Log.Warning("Missing FR in TR-per-FR-Mapping:"); + result.MissingFrInMapping.ForEach(id => Log.Warning(" - {Id}", id)); + } + + if (result.MissingFrInMatrix.Count > 0) + { + Log.Warning("Missing FR in Requirements-Matrix:"); + result.MissingFrInMatrix.ForEach(id => Log.Warning(" - {Id}", id)); + } + + if (result.MissingTrInMatrix.Count > 0) + { + Log.Warning("Missing TR in Requirements-Matrix:"); + result.MissingTrInMatrix.ForEach(id => Log.Warning(" - {Id}", id)); + } + + if (result.MissingTestInMatrix.Count > 0) + { + Log.Warning("Missing TEST in Requirements-Matrix:"); + result.MissingTestInMatrix.ForEach(id => Log.Warning(" - {Id}", id)); + } + + var fail = result.HasFrErrors || + (StrictTrAndTestCoverage && (result.HasTrErrors || result.HasTestErrors)); + + if (fail) + throw new InvalidOperationException("Traceability validation failed."); + + if (result.HasTrErrors || result.HasTestErrors) + Log.Information("Traceability validation passed with TR/TEST coverage warnings."); + else + Log.Information("Traceability validation passed."); + }); +} diff --git a/build/Build.cs b/build/Build.cs new file mode 100644 index 0000000..a50b6d0 --- /dev/null +++ b/build/Build.cs @@ -0,0 +1,30 @@ +using Nuke.Common; +using Nuke.Common.IO; +using Nuke.Common.ProjectModel; +using Nuke.Common.Tools.DotNet; + +/// +/// Main Nuke build orchestration entry point. +/// +partial class Build : NukeBuild +{ + public static int Main() => Execute(x => x.Compile); + + [Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] + public readonly string Configuration = IsLocalBuild ? "Debug" : "Release"; + + [Solution(SuppressBuildProjectCheck = true)] + readonly Solution Solution; + + /// Root directory of the repository. + public AbsolutePath SourceDirectory => RootDirectory / "src"; + + /// Test projects directory. + public AbsolutePath TestsDirectory => RootDirectory / "tests"; + + /// Build artifacts output directory. + public AbsolutePath ArtifactsDirectory => RootDirectory / "artifacts"; + + /// Local NuGet packages output directory. + public AbsolutePath LocalPackagesDirectory => RootDirectory / "local-packages"; +} diff --git a/build/ConfigValidator.cs b/build/ConfigValidator.cs new file mode 100644 index 0000000..b373c55 --- /dev/null +++ b/build/ConfigValidator.cs @@ -0,0 +1,194 @@ +using System.Text.RegularExpressions; + +/// +/// Validates MCP appsettings instance configuration (YAML format). +/// Ported from scripts/Validate-McpConfig.ps1. +/// +static partial class ConfigValidator +{ + [GeneratedRegex(@"^Mcp:\s*$")] + private static partial Regex McpSectionRegex(); + + [GeneratedRegex(@"^ Instances:\s*$")] + private static partial Regex InstancesSectionRegex(); + + [GeneratedRegex(@"^ ([A-Za-z0-9_][A-Za-z0-9_\-]*):\s*$")] + private static partial Regex InstanceNameRegex(); + + [GeneratedRegex(@"^ RepoRoot:\s*(.+)$")] + private static partial Regex RepoRootRegex(); + + [GeneratedRegex(@"^ Port:\s*(.+)$")] + private static partial Regex PortRegex(); + + [GeneratedRegex(@"^ TodoStorage:\s*$")] + private static partial Regex TodoStorageSectionRegex(); + + [GeneratedRegex(@"^ Provider:\s*(.+)$")] + private static partial Regex ProviderRegex(); + + [GeneratedRegex(@"^ SqliteDataSource:\s*(.+)$")] + private static partial Regex SqliteDataSourceRegex(); + + /// Represents a parsed MCP instance from YAML. + public sealed class InstanceConfig + { + public string? RepoRoot { get; set; } + public int? Port { get; set; } + public string? TodoProvider { get; set; } + public string? SqliteDataSource { get; set; } + } + + /// + /// Parses MCP instance configurations from YAML content lines. + /// Returns null if no Mcp section is found. + /// + public static Dictionary? ParseInstances(string[] lines) + { + var hasMcp = false; + var instances = new Dictionary(StringComparer.OrdinalIgnoreCase); + var inInstances = false; + string? currentInstance = null; + var inTodoStorage = false; + + foreach (var rawLine in lines) + { + var line = rawLine.TrimEnd(); + if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) + continue; + + if (McpSectionRegex().IsMatch(line)) + { + hasMcp = true; + continue; + } + + if (!hasMcp) continue; + + if (InstancesSectionRegex().IsMatch(line)) + { + inInstances = true; + currentInstance = null; + inTodoStorage = false; + continue; + } + + if (!inInstances) continue; + + // A sibling key under Mcp ends the Instances block + if (Regex.IsMatch(line, @"^ [A-Za-z0-9_][A-Za-z0-9_\-]*:\s*$") && !InstancesSectionRegex().IsMatch(line)) + break; + + var instanceMatch = InstanceNameRegex().Match(line); + if (instanceMatch.Success) + { + currentInstance = instanceMatch.Groups[1].Value; + instances[currentInstance] = new InstanceConfig(); + inTodoStorage = false; + continue; + } + + if (currentInstance is null) continue; + + if (TodoStorageSectionRegex().IsMatch(line)) + { + inTodoStorage = true; + continue; + } + + var repoRootMatch = RepoRootRegex().Match(line); + if (repoRootMatch.Success) + { + instances[currentInstance].RepoRoot = UnquoteScalar(repoRootMatch.Groups[1].Value); + inTodoStorage = false; + continue; + } + + var portMatch = PortRegex().Match(line); + if (portMatch.Success) + { + if (int.TryParse(UnquoteScalar(portMatch.Groups[1].Value), out var port)) + instances[currentInstance].Port = port; + inTodoStorage = false; + continue; + } + + if (inTodoStorage) + { + var providerMatch = ProviderRegex().Match(line); + if (providerMatch.Success) + { + instances[currentInstance].TodoProvider = UnquoteScalar(providerMatch.Groups[1].Value); + continue; + } + + var sqliteMatch = SqliteDataSourceRegex().Match(line); + if (sqliteMatch.Success) + { + instances[currentInstance].SqliteDataSource = UnquoteScalar(sqliteMatch.Groups[1].Value); + } + } + } + + return hasMcp ? instances : null; + } + + /// + /// Validates parsed instances for port conflicts, missing required fields, and valid providers. + /// Returns a list of validation error messages. Empty list means valid. + /// + public static List Validate(Dictionary instances, Func? directoryExists = null) + { + var errors = new List(); + var ports = new Dictionary(); + directoryExists ??= Directory.Exists; + + foreach (var (name, instance) in instances) + { + if (string.IsNullOrWhiteSpace(instance.RepoRoot)) + { + errors.Add($"Instance '{name}' missing RepoRoot."); + continue; + } + + if (!directoryExists(instance.RepoRoot)) + errors.Add($"Instance '{name}' RepoRoot does not exist: '{instance.RepoRoot}'."); + + if (instance.Port is null or <= 0) + { + errors.Add($"Instance '{name}' has invalid port."); + continue; + } + + if (ports.TryGetValue(instance.Port.Value, out var existing)) + errors.Add($"Duplicate port '{instance.Port}' in instances '{existing}' and '{name}'."); + else + ports[instance.Port.Value] = name; + + var provider = (instance.TodoProvider ?? "yaml").Trim().ToLowerInvariant(); + if (provider is not "yaml" and not "sqlite") + { + errors.Add($"Instance '{name}' has unsupported TodoStorage provider '{provider}'. Allowed: yaml, sqlite."); + continue; + } + + if (provider == "sqlite" && string.IsNullOrWhiteSpace(instance.SqliteDataSource)) + errors.Add($"Instance '{name}' provider sqlite requires TodoStorage.SqliteDataSource."); + } + + return errors; + } + + private static string UnquoteScalar(string value) + { + var trimmed = value.Trim(); + if (trimmed.Length >= 2 && + ((trimmed[0] == '\'' && trimmed[^1] == '\'') || + (trimmed[0] == '"' && trimmed[^1] == '"'))) + { + return trimmed[1..^1]; + } + + return trimmed; + } +} diff --git a/build/GitVersionBumper.cs b/build/GitVersionBumper.cs new file mode 100644 index 0000000..81abb53 --- /dev/null +++ b/build/GitVersionBumper.cs @@ -0,0 +1,43 @@ +using System.Text.RegularExpressions; + +/// +/// Parses and increments the patch component of the next-version field in GitVersion.yml. +/// +static partial class GitVersionBumper +{ + [GeneratedRegex(@"(?m)^(next-version:\s*)(\d+)\.(\d+)\.(\d+)")] + private static partial Regex NextVersionRegex(); + + /// + /// Parses the next-version from GitVersion.yml content. + /// + public static (int Major, int Minor, int Patch)? ParseVersion(string content) + { + var match = NextVersionRegex().Match(content); + if (!match.Success) + return null; + + return ( + int.Parse(match.Groups[2].Value), + int.Parse(match.Groups[3].Value), + int.Parse(match.Groups[4].Value)); + } + + /// + /// Bumps the patch version in GitVersion.yml content and returns the updated content + /// along with old and new version strings. + /// + public static (string NewContent, string OldVersion, string NewVersion)? BumpPatch(string content) + { + var version = ParseVersion(content); + if (version is null) + return null; + + var (major, minor, patch) = version.Value; + var oldVersion = $"{major}.{minor}.{patch}"; + var newVersion = $"{major}.{minor}.{patch + 1}"; + + var newContent = NextVersionRegex().Replace(content, $"${{1}}{newVersion}"); + return (newContent, oldVersion, newVersion); + } +} diff --git a/build/MsixHelper.cs b/build/MsixHelper.cs new file mode 100644 index 0000000..35481b4 --- /dev/null +++ b/build/MsixHelper.cs @@ -0,0 +1,77 @@ +/// +/// Utilities for MSIX packaging: SDK tool resolution and AppxManifest generation. +/// Ported from scripts/Package-McpServerMsix.ps1. +/// +static class MsixHelper +{ + private static readonly string WindowsKitsRoot = @"C:\Program Files (x86)\Windows Kits\10\bin"; + + /// + /// Searches for a Windows SDK tool (makeappx.exe, signtool.exe) on PATH + /// and in the Windows 10 SDK installation directory. + /// + public static string? FindSdkTool(string toolName) + { + // Check PATH first + var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? []; + foreach (var dir in pathDirs) + { + var candidate = Path.Combine(dir, toolName); + if (File.Exists(candidate)) + return candidate; + } + + // Search Windows SDK directories + if (!Directory.Exists(WindowsKitsRoot)) + return null; + + return Directory.EnumerateFiles(WindowsKitsRoot, toolName, SearchOption.AllDirectories) + .Where(f => f.Contains(@"\x64\", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(f => f) + .FirstOrDefault(); + } + + /// + /// Generates AppxManifest.xml content for the MSIX package. + /// + public static string GenerateManifest(string packageName, string publisher, string version) + { + return $""" + + + + + {packageName} + FunWasHad + Square44x44Logo.png + + + + + + + + + + + + + + + + + """; + } + + /// + /// Creates a 1x1 transparent PNG placeholder for required MSIX logo assets. + /// + public static byte[] CreatePlaceholderPng() + { + return Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5oY0QAAAAASUVORK5CYII="); + } +} diff --git a/build/TraceabilityValidator.cs b/build/TraceabilityValidator.cs new file mode 100644 index 0000000..5627a48 --- /dev/null +++ b/build/TraceabilityValidator.cs @@ -0,0 +1,135 @@ +using System.Text.RegularExpressions; + +/// +/// Validates requirements traceability between FR/TR/TEST documents and the mapping/matrix files. +/// Ported from scripts/Validate-RequirementsTraceability.ps1. +/// +static partial class TraceabilityValidator +{ + [GeneratedRegex(@"^##\s+(FR-[A-Z0-9-]+-\d{3})\b")] + private static partial Regex FrHeadingRegex(); + + [GeneratedRegex(@"^##\s+(TR-[A-Z0-9-]+-\d{3})\b")] + private static partial Regex TrHeadingRegex(); + + [GeneratedRegex(@"\b(TEST-[A-Z]+-\d{3})\b")] + private static partial Regex TestIdRegex(); + + [GeneratedRegex(@"^\|\s*(FR-[A-Z0-9-]+-\d{3})")] + private static partial Regex MappingFrRegex(); + + [GeneratedRegex(@"^\|\s*((?:FR|TR|TEST)-[A-Z0-9-]+-\d{3}(?:[–-]\d{3})?)")] + private static partial Regex MatrixIdRegex(); + + [GeneratedRegex(@"^([A-Z]+(?:-[A-Z0-9]+)+-)(\d{3})[–-](\d{3})$")] + private static partial Regex RangeTokenRegex(); + + /// Extracts requirement IDs from heading lines matching a given prefix regex. + public static List GetIdsFromHeadings(string[] lines, Regex pattern) + { + var ids = new List(); + foreach (var line in lines) + { + var match = pattern.Match(line); + if (match.Success) + ids.Add(match.Groups[1].Value); + } + return ids; + } + + /// Extracts all TEST-* IDs from content lines. + public static HashSet GetTestIds(string[] lines) + { + var ids = new HashSet(StringComparer.Ordinal); + foreach (var line in lines) + { + foreach (Match match in TestIdRegex().Matches(line)) + ids.Add(match.Groups[1].Value); + } + return ids; + } + + /// Extracts FR IDs from the TR-per-FR mapping file. + public static List GetMappingFrIds(string[] lines) + { + var ids = new List(); + foreach (var line in lines) + { + var match = MappingFrRegex().Match(line); + if (match.Success) + ids.Add(match.Groups[1].Value); + } + return ids; + } + + /// Extracts requirement IDs from the matrix file, expanding range tokens. + public static HashSet GetMatrixIds(string[] lines) + { + var ids = new HashSet(StringComparer.Ordinal); + foreach (var line in lines) + { + var match = MatrixIdRegex().Match(line); + if (!match.Success) continue; + + foreach (var expanded in ExpandRangeToken(match.Groups[1].Value)) + ids.Add(expanded); + } + return ids; + } + + /// Expands a range token like FR-MCP-001-003 into individual IDs. + public static IEnumerable ExpandRangeToken(string token) + { + var match = RangeTokenRegex().Match(token); + if (!match.Success) + return [token]; + + var prefix = match.Groups[1].Value; + var start = int.Parse(match.Groups[2].Value); + var end = int.Parse(match.Groups[3].Value); + + if (end < start) + return [token]; + + return Enumerable.Range(start, end - start + 1) + .Select(i => $"{prefix}{i:D3}"); + } + + /// Result of traceability validation. + public sealed class ValidationResult + { + public List MissingFrInMapping { get; init; } = []; + public List MissingFrInMatrix { get; init; } = []; + public List MissingTrInMatrix { get; init; } = []; + public List MissingTestInMatrix { get; init; } = []; + + public bool HasFrErrors => MissingFrInMapping.Count > 0 || MissingFrInMatrix.Count > 0; + public bool HasTrErrors => MissingTrInMatrix.Count > 0; + public bool HasTestErrors => MissingTestInMatrix.Count > 0; + } + + /// + /// Validates traceability across all requirements documents. + /// + public static ValidationResult Validate( + string[] functionalLines, + string[] technicalLines, + string[] testingLines, + string[] mappingLines, + string[] matrixLines) + { + var frIds = GetIdsFromHeadings(functionalLines, FrHeadingRegex()); + var trIds = GetIdsFromHeadings(technicalLines, TrHeadingRegex()); + var testIds = GetTestIds(testingLines); + var mappingFr = GetMappingFrIds(mappingLines); + var matrixIds = GetMatrixIds(matrixLines); + + return new ValidationResult + { + MissingFrInMapping = frIds.Where(id => !mappingFr.Contains(id)).ToList(), + MissingFrInMatrix = frIds.Where(id => !matrixIds.Contains(id)).ToList(), + MissingTrInMatrix = trIds.Where(id => !matrixIds.Contains(id)).ToList(), + MissingTestInMatrix = testIds.Where(id => !matrixIds.Contains(id)).ToList(), + }; + } +} diff --git a/build/_build.csproj b/build/_build.csproj new file mode 100644 index 0000000..3d40660 --- /dev/null +++ b/build/_build.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + _build + false + CS1591 + false + 1 + + + + + + + + + + + diff --git a/docs/FAQ.md b/docs/FAQ.md index 0e4a570..25b32f0 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -18,7 +18,8 @@ MCP Server is a local AI-agent integration server that exposes project context **From the command line (development):** ```bash -dotnet run --project src/McpServer.Support.Mcp -- --instance default +./build.ps1 StartServer --instance default +# or: dotnet run --project src/McpServer.Support.Mcp -- --instance default ``` **Over STDIO (for MCP clients that prefer stdin/stdout):** @@ -45,7 +46,7 @@ Workspace instances are hosted as in-process Kestrel listeners starting at port { "mcpServers": { "mcp-server": { - "url": "/mcp-transport" + "url": "/mcp-transport" } } } @@ -58,7 +59,7 @@ Workspace instances are hosted as in-process Kestrel listeners starting at port "servers": { "mcp-server": { "type": "sse", - "url": "/mcp-transport" + "url": "/mcp-transport" } } } @@ -79,15 +80,15 @@ Two backends are available, configured via `Mcp:TodoStorage:Provider`: | `yaml` (default) | `docs/Project/TODO.yaml` file | Human-readable, version-controlled | | `sqlite` | `mcp.db` SQLite database | High-volume, concurrent access | -### How are TODO IDs structured? - -Persisted TODO IDs follow one of two canonical forms: - -- `--###` for standard workspace TODOs (for example, `MCP-AUTH-001`) -- `ISSUE-{number}` for GitHub-backed TODOs (for example, `ISSUE-17`) - -Create requests may also use `ISSUE-NEW`. The server immediately creates a GitHub issue, determines the -issue number, and saves the TODO using the canonical `ISSUE-{number}` id. +### How are TODO IDs structured? + +Persisted TODO IDs follow one of two canonical forms: + +- `--###` for standard workspace TODOs (for example, `MCP-AUTH-001`) +- `ISSUE-{number}` for GitHub-backed TODOs (for example, `ISSUE-17`) + +Create requests may also use `ISSUE-NEW`. The server immediately creates a GitHub issue, determines the +issue number, and saves the TODO using the canonical `ISSUE-{number}` id. ### Can I sync TODOs with GitHub Issues? @@ -97,10 +98,10 @@ Yes. Bidirectional sync is available: - **TODO → GitHub**: `POST /mcpserver/gh/issues/sync/to-github` - **Single issue**: `POST /mcpserver/gh/issues/{number}/sync` -Synced items get `ISSUE-{number}` IDs. Status changes (done ↔ closed) propagate in both directions. -For existing `ISSUE-*` items, MCP TODO priority is authoritative and syncs to canonical GitHub labels such -as `priority: HIGH`. After the first sync, ISSUE descriptions remain unchanged and later TODO updates add a -GitHub issue comment summarizing the change set. +Synced items get `ISSUE-{number}` IDs. Status changes (done ↔ closed) propagate in both directions. +For existing `ISSUE-*` items, MCP TODO priority is authoritative and syncs to canonical GitHub labels such +as `priority: HIGH`. After the first sync, ISSUE descriptions remain unchanged and later TODO updates add a +GitHub issue comment summarizing the change set. --- diff --git a/docs/MCP-SERVER.md b/docs/MCP-SERVER.md index 74461bb..ff4e1ef 100644 --- a/docs/MCP-SERVER.md +++ b/docs/MCP-SERVER.md @@ -13,12 +13,12 @@ Standalone repository for `McpServer.Support.Mcp`, the MCP context server used f ## Repository Layout -- `src/McpServer.Support.Mcp` - server application -- `tests/McpServer.Support.Mcp.Tests` - unit/integration tests -- `MCP-SERVER.md` - detailed operational and configuration guide -- `AZURE-PIPELINES.md` - Azure DevOps CI/CD variables and retention notes -- `scripts` - run, validate, test, migration, extension, and packaging scripts -- `azure-pipelines.yml` - Azure DevOps pipeline (build/test/artifacts/MSIX/docs quality/package publish) +- `src/McpServer.Support.Mcp` - server application +- `tests/McpServer.Support.Mcp.Tests` - unit/integration tests +- `MCP-SERVER.md` - detailed operational and configuration guide +- `AZURE-PIPELINES.md` - Azure DevOps CI/CD variables and retention notes +- `scripts` - run, validate, test, migration, extension, and packaging scripts +- `azure-pipelines.yml` - Azure DevOps pipeline (build/test/artifacts/MSIX/docs quality/package publish) ## Prerequisites @@ -32,14 +32,15 @@ Standalone repository for `McpServer.Support.Mcp`, the MCP context server used f 1. Restore and build: ```powershell -dotnet restore McpServer.sln -dotnet build McpServer.sln -c Staging +./build.ps1 Compile --configuration Staging +# or: dotnet restore McpServer.sln && dotnet build McpServer.sln -c Staging ``` 1. Run the default instance: ```powershell -.\scripts\Start-McpServer.ps1 -Configuration Staging -Instance default +./build.ps1 StartServer --instance default +# or: dotnet run --project src\McpServer.Support.Mcp\McpServer.Support.Mcp.csproj -c Staging -- --instance default ``` 1. Open Swagger: @@ -119,14 +120,14 @@ Environment overrides: Run two configured instances: ```powershell -.\scripts\Start-McpServer.ps1 -Configuration Staging -Instance default -.\scripts\Start-McpServer.ps1 -Configuration Staging -Instance alt-local +./build.ps1 StartServer --instance default +./build.ps1 StartServer --instance alt-local ``` Smoke test both instances: ```powershell -.\scripts\Test-McpMultiInstance.ps1 -Configuration Staging -FirstInstance default -SecondInstance alt-local +./build.ps1 TestMultiInstance --first-instance default --second-instance alt-local ``` Migrate todo data between backends: @@ -135,16 +136,18 @@ Migrate todo data between backends: .\scripts\Migrate-McpTodoStorage.ps1 -SourceBaseUrl http://localhost:7147 -TargetBaseUrl http://localhost:7157 ``` +## Build System + +Build-related tasks are available as Nuke targets via `./build.ps1`. See the [Build System section in README.md](../README.md#build-system) for the full target list. + ## Common Scripts -- `scripts/Start-McpServer.ps1` - build/run server with optional `-Instance` +The following operational/admin scripts are not part of the Nuke build pipeline: + - `scripts/Run-McpServer.ps1` - direct local run helper - `scripts/Update-McpService.ps1` - stop, publish Debug build, restore config/data, restart, health-check Windows service -- `scripts/Validate-McpConfig.ps1` - config validation -- `scripts/Test-McpMultiInstance.ps1` - two-instance smoke test -- `scripts/Test-GraphRagSmoke.ps1` - GraphRAG status/index/query smoke validation +- `scripts/Manage-McpService.ps1` - install/start/stop/remove Windows service - `scripts/Migrate-McpTodoStorage.ps1` - todo backend migration -- `scripts/Package-McpServerMsix.ps1` - publish and package MSIX ## GraphRAG @@ -204,8 +207,8 @@ Track these operational indicators during rollout: ## Build and Test ```powershell -dotnet build McpServer.sln -c Staging -dotnet test tests\McpServer.Support.Mcp.Tests\McpServer.Support.Mcp.Tests.csproj -c Debug +./build.ps1 Compile --configuration Staging +./build.ps1 Test ``` ## API Surface @@ -223,17 +226,17 @@ Main endpoints: ## CI/CD -Pipeline: `azure-pipelines.yml` - -Pipeline jobs include: - -- config validation -- restore/build/test -- publish artifact upload -- Windows MSIX packaging -- markdown lint and link checking for docs -- DocFX docs artifact build -- client NuGet pack and branch-conditional feed publish +Pipeline: `azure-pipelines.yml` + +Pipeline jobs include: + +- config validation +- restore/build/test +- publish artifact upload +- Windows MSIX packaging +- markdown lint and link checking for docs +- DocFX docs artifact build +- client NuGet pack and branch-conditional feed publish ## VS Code / VS 2026 Extensions @@ -269,7 +272,7 @@ var client = McpServerClientFactory.Create(new McpServerClientOptions Covers all API endpoints: Todo, Context, SessionLog, GitHub, Repo, Sync, Workspace, and Tools. -Source: `src/McpServer.Client/` — see the [package README](https://github.com/sharpninja/McpServer/blob/develop/src/McpServer.Client/README.md) for full usage. +Source: `src/McpServer.Client/` — see the [package README](https://github.com/sharpninja/McpServer/blob/develop/src/McpServer.Client/README.md) for full usage. ## Additional Documentation diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index 412c5e6..86abdd6 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -4,8 +4,10 @@ ### Build & Test -- [ ] `dotnet build McpServer.sln -c Release` succeeds with 0 errors, 0 warnings -- [ ] `dotnet run --project tests/McpServer.Support.Mcp.Tests` — all tests pass (target: 236+) +- [ ] `./build.ps1 Compile --configuration Release` succeeds with 0 errors, 0 warnings +- [ ] `./build.ps1 Test` — all tests pass (target: 236+) +- [ ] `./build.ps1 ValidateConfig` — config validation passes +- [ ] `./build.ps1 ValidateTraceability` — requirements coverage passes - [ ] Docker build succeeds: `docker build -t mcp-server:latest .` - [ ] Container health check passes: `curl http://localhost:7147/health` @@ -22,9 +24,9 @@ ### Configuration -- [ ] `appsettings.json` has all required keys with sensible defaults -- [ ] `C:\ProgramData\McpServer\appsettings.json` is the canonical Windows service config (no `appsettings.Production.json` override) -- [ ] Environment variable overrides work (Mcp__Port, Mcp__RepoRoot, etc.) +- [ ] `appsettings.json` has all required keys with sensible defaults +- [ ] `C:\ProgramData\McpServer\appsettings.json` is the canonical Windows service config (no `appsettings.Production.json` override) +- [ ] Environment variable overrides work (Mcp__Port, Mcp__RepoRoot, etc.) - [ ] Feature toggles (Embedding:Enabled, VectorIndex:Enabled) respect settings - [ ] Per-instance TODO storage backend selection works (YAML and SQLite) @@ -38,16 +40,16 @@ ## Release Steps -1. **Version bump**: Update `.version` file -2. **Final test run**: `dotnet run --project tests/McpServer.Support.Mcp.Tests` +1. **Version bump**: `./build.ps1 BumpVersion` or update `.version` file +2. **Final test run**: `./build.ps1 Test` 3. **Docker build**: `docker build -t mcp-server:$(cat .version) -t mcp-server:latest .` 4. **Tag release**: `git tag v$(cat .version) && git push origin v$(cat .version)` -5. **CI publish**: Azure DevOps `publish-packages` job publishes `McpServer.Client` on `main` when `NuGetApiKey` is configured -6. **MSIX package**: Azure DevOps `windows-msix` job publishes the installer artifact +5. **CI publish**: Azure DevOps `publish-packages` job publishes `McpServer.Client` on `main` when `NuGetApiKey` is configured +6. **MSIX package**: Azure DevOps `windows-msix` job publishes the installer artifact ## Post-Release Verification -- [ ] Azure DevOps pipeline run completed with the expected published artifacts +- [ ] Azure DevOps pipeline run completed with the expected published artifacts - [ ] Docker image runs and passes health check - [ ] MSIX installer works on clean Windows machine - [ ] FunWasHad workspace can connect to released MCP server diff --git a/docs/REPL-MIGRATION-GUIDE.md b/docs/REPL-MIGRATION-GUIDE.md new file mode 100644 index 0000000..684c38a --- /dev/null +++ b/docs/REPL-MIGRATION-GUIDE.md @@ -0,0 +1,217 @@ +# Migrating from Direct API to REPL Host Workflows + +This guide tells agents how to replace direct `McpServerClient` HTTP calls for session logging and TODO management with the REPL-backed workflow tools now available in `McpAgent`. + +## Why Migrate + +The hosted McpAgent now exposes **27 tools** through the AI function surface. The 10 new REPL-backed tools provide: + +- **Requirements management** (FR/TR/TEST list and get) without raw HTTP calls +- **TODO create/delete** alongside existing query/get/update +- **Session log history** queries across agents +- **Generic client passthrough** for any sub-client method not covered by a dedicated tool + +Using these tools instead of raw API calls ensures consistent identifier validation, canonical formatting, and proper audit trails. + +## Tool Inventory + +### Session Log (6 tools) + +| Tool | Replaces | Description | +|------|----------|-------------| +| `mcp_session_bootstrap` | `POST /mcpserver/sessionlog` | Bootstrap a new session log | +| `mcp_session_update` | `POST /mcpserver/sessionlog` | Update session-level metadata | +| `mcp_session_turn_begin` | `POST /mcpserver/sessionlog` | Create a new turn | +| `mcp_session_turn_update` | `POST /mcpserver/sessionlog` | Update an existing turn | +| `mcp_session_turn_complete` | `POST /mcpserver/sessionlog` | Complete a turn | +| `mcp_session_query_history` | `GET /mcpserver/sessionlog` | **NEW** - Query session history | + +### TODO (7 tools) + +| Tool | Replaces | Description | +|------|----------|-------------| +| `mcp_todo_query` | `GET /mcpserver/todo` | Query TODO items with filters | +| `mcp_todo_get` | `GET /mcpserver/todo/{id}` | Get a single TODO by ID | +| `mcp_todo_update` | `PUT /mcpserver/todo/{id}` | Update a TODO item | +| `mcp_todo_create` | `POST /mcpserver/todo` | **NEW** - Create a TODO item | +| `mcp_todo_delete` | `DELETE /mcpserver/todo/{id}` | **NEW** - Delete a TODO item | +| `mcp_todo_plan` | `GET /mcpserver/todo/{id}/plan` | Get buffered plan text | +| `mcp_todo_status` | `GET /mcpserver/todo/{id}/status` | Get buffered status report | +| `mcp_todo_implementation` | `GET /mcpserver/todo/{id}/implementation` | Get implementation guide | + +### Requirements (6 tools, all NEW) + +| Tool | Description | +|------|-------------| +| `mcp_requirements_list_fr` | List functional requirements (optional area/status filter) | +| `mcp_requirements_list_tr` | List technical requirements (optional area/subarea/status filter) | +| `mcp_requirements_list_test` | List test requirements (optional area/status filter) | +| `mcp_requirements_get_fr` | Get a specific FR by ID (e.g. `FR-MCP-001`) | +| `mcp_requirements_get_tr` | Get a specific TR by ID (e.g. `TR-MCP-ARCH-001`) | +| `mcp_requirements_get_test` | Get a specific TEST by ID (e.g. `TEST-MCP-001`) | + +### Repository (3 tools) + +| Tool | Description | +|------|-------------| +| `mcp_repo_read` | Read file content by relative path | +| `mcp_repo_list` | List files/directories | +| `mcp_repo_write` | Write file content by relative path | + +### Desktop and PowerShell (4 tools) + +| Tool | Description | +|------|-------------| +| `mcp_desktop_launch` | Launch a local desktop process | +| `mcp_powershell_session_create` | Create a persistent PowerShell session | +| `mcp_powershell_session_command` | Run a command in a PowerShell session | +| `mcp_powershell_session_close` | Close a PowerShell session | + +### Generic Passthrough (1 tool, NEW) + +| Tool | Description | +|------|-------------| +| `mcp_client_invoke` | Dynamically invoke any McpServerClient sub-client method | + +## Migration Patterns + +### Before: Direct Session Log API Calls + +``` +# Old pattern - raw HTTP via PowerShell or curl +POST /mcpserver/sessionlog +{ + "sourceType": "Copilot", + "sessionId": "Copilot-20260402T...", + ... +} +``` + +### After: Use Session Log Tools + +``` +# Bootstrap +mcp_session_bootstrap({ + sessionId: null, // auto-generated + title: "Implement auth flow", + model: "claude-opus-4-6", + status: "in_progress" +}) + +# Begin turn +mcp_session_turn_begin({ + requestId: null, // auto-generated + queryTitle: "Add login endpoint", + queryText: "Create POST /auth/login with JWT response" +}) + +# Complete turn +mcp_session_turn_complete({ + requestId: "req-20260402T120000Z-add-login", + response: "Created LoginController with JWT token generation" +}) + +# Query history (NEW) +mcp_session_query_history({ + agent: "Copilot", + limit: 5 +}) +``` + +### Before: Direct TODO API Calls + +``` +# Old pattern +GET /mcpserver/todo?keyword=auth&priority=high +POST /mcpserver/todo { id: "PLAN-AUTH-001", ... } +DELETE /mcpserver/todo/PLAN-AUTH-001 +``` + +### After: Use TODO Tools + +``` +# Query +mcp_todo_query({ keyword: "auth", priority: "high" }) + +# Create (NEW) +mcp_todo_create({ + id: "PLAN-AUTH-001", + title: "Implement OAuth2 device flow", + section: "Authentication", + priority: "high", + estimate: "4h" +}) + +# Delete (NEW) +mcp_todo_delete({ id: "PLAN-AUTH-001" }) +``` + +### Before: Raw Requirements API Calls + +``` +# Old pattern +GET /mcpserver/requirements/fr +GET /mcpserver/requirements/tr/TR-MCP-ARCH-001 +``` + +### After: Use Requirements Tools + +``` +# List FRs filtered by area +mcp_requirements_list_fr({ area: "MCP" }) + +# Get specific TR +mcp_requirements_get_tr({ id: "TR-MCP-ARCH-001" }) + +# Get all test requirements +mcp_requirements_list_test({}) +``` + +### Generic Passthrough for Uncovered Operations + +For any McpServerClient sub-client method not covered by a dedicated tool: + +``` +# Search workspace context +mcp_client_invoke({ + clientName: "context", + methodName: "SearchAsync", + arguments: { query: "authentication flow", limit: 10 } +}) + +# List GitHub issues +mcp_client_invoke({ + clientName: "github", + methodName: "ListIssuesAsync", + arguments: { state: "open" } +}) + +# Check workspace health +mcp_client_invoke({ + clientName: "health", + methodName: "CheckAsync", + arguments: {} +}) +``` + +## Identifier Rules (Unchanged) + +These canonical formats are enforced by both the old API and the new tools: + +- **Session ID**: `--` (e.g. `Copilot-20260402T120000Z-authflow`) +- **Request ID**: `req--` (e.g. `req-20260402T120000Z-add-login-001`) +- **TODO ID**: `--###` or `ISSUE-{number}` (e.g. `PLAN-AUTH-001`, `ISSUE-42`) +- **FR ID**: `FR--###` (e.g. `FR-MCP-001`) +- **TR ID**: `TR---###` (e.g. `TR-MCP-ARCH-001`) +- **TEST ID**: `TEST--###` (e.g. `TEST-MCP-001`) + +When `sessionId` or `requestId` is passed as `null`, the tool auto-generates a canonical ID. + +## Summary of Changes for Agent Authors + +1. **Stop making raw HTTP calls** to `/mcpserver/sessionlog`, `/mcpserver/todo`, and `/mcpserver/requirements`. Use the named tools instead. +2. **Use `mcp_todo_create` and `mcp_todo_delete`** for full TODO lifecycle instead of raw POST/DELETE. +3. **Use `mcp_requirements_list_*` and `mcp_requirements_get_*`** for requirements queries instead of raw GET. +4. **Use `mcp_session_query_history`** to review past sessions instead of raw query endpoints. +5. **Use `mcp_client_invoke`** as an escape hatch for any sub-client method not covered by dedicated tools (context search, GitHub, workspace management, voice, tunnels, etc.). +6. **PowerShell helper modules** (`McpContext.psm1`) are still valid for interactive shell workflows but agents running inside McpAgent should prefer the tool surface. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index e1e3d13..a0f3c6d 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -25,7 +25,8 @@ Invoke-RestMethod http://localhost:7147/health #### Development run (HTTP + MCP transport) ```powershell -dotnet run --project src\McpServer.Support.Mcp -- --instance default +./build.ps1 StartServer --instance default +# or: dotnet run --project src\McpServer.Support.Mcp -- --instance default ``` #### STDIO transport diff --git a/index.md b/index.md index 189a335..a40562d 100644 --- a/index.md +++ b/index.md @@ -22,8 +22,8 @@ Welcome to the MCP Server documentation. MCP Server is a .NET 9/ASP.NET Core app ## Getting Started -1. **Build**: `dotnet build src\McpServer.Support.Mcp\McpServer.Support.Mcp.csproj` -2. **Run**: `dotnet run --project src\McpServer.Support.Mcp` +1. **Build**: `./build.ps1 Compile` (or `dotnet build McpServer.sln`) +2. **Run**: `./build.ps1 StartServer` (or `dotnet run --project src\McpServer.Support.Mcp`) 3. **Install as service**: `.\scripts\Manage-McpService.ps1 -Action Install` See the [FAQ](docs/FAQ.md) for detailed setup instructions. diff --git a/src/McpServer.Client/AuthConfigClient.cs b/src/McpServer.Client/AuthConfigClient.cs index 3d483cf..3007853 100644 --- a/src/McpServer.Client/AuthConfigClient.cs +++ b/src/McpServer.Client/AuthConfigClient.cs @@ -1,25 +1,48 @@ -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using McpServer.Client.Models; - -namespace McpServer.Client; - -/// -/// Client for public auth configuration endpoint (/auth/config). -/// -public sealed class AuthConfigClient : McpClientBase -{ - /// - public AuthConfigClient(HttpClient http, McpServerClientOptions options) - : base(http, options) { } - - internal AuthConfigClient(HttpClient http, McpServerClientOptions options, WorkspacePathHolder holder) - : base(http, options, holder) { } - - /// Gets public OIDC configuration metadata. - public async Task GetConfigAsync(CancellationToken cancellationToken = default) - { - return await GetAsync("auth/config", cancellationToken); - } -} +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using McpServer.Client.Models; + +namespace McpServer.Client; + +/// +/// Client for public auth configuration endpoint (/auth/config). +/// This endpoint is unauthenticated, so requests bypass the base class auth check. +/// +public sealed class AuthConfigClient : McpClientBase +{ + private readonly HttpClient _http; + private readonly string _scheme; + private readonly string _host; + + /// + public AuthConfigClient(HttpClient http, McpServerClientOptions options) + : base(http, options) + { + _http = http; + _scheme = options.BaseUrl.Scheme; + _host = options.BaseUrl.Host; + } + + internal AuthConfigClient(HttpClient http, McpServerClientOptions options, WorkspacePathHolder holder) + : base(http, options, holder) + { + _http = http; + _scheme = options.BaseUrl.Scheme; + _host = options.BaseUrl.Host; + } + + /// Gets public OIDC configuration metadata. No authentication required. + public async Task GetConfigAsync(CancellationToken cancellationToken = default) + { + var uri = new Uri($"{_scheme}://{_host}:{Port}/auth/config"); + using var response = await _http.GetAsync(uri, cancellationToken); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(cancellationToken); + return JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + })!; + } +} diff --git a/src/McpServer.McpAgent/Hosting/McpHostedAgent.cs b/src/McpServer.McpAgent/Hosting/McpHostedAgent.cs index 9385659..79b638d 100644 --- a/src/McpServer.McpAgent/Hosting/McpHostedAgent.cs +++ b/src/McpServer.McpAgent/Hosting/McpHostedAgent.cs @@ -1,12 +1,14 @@ -using McpServer.McpAgent.SessionLog; -using McpServer.McpAgent.Todo; using McpServer.McpAgent.PowerShellSessions; using McpServer.Client; +using McpServer.Repl.Core; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using IAgentSessionLogWorkflow = McpServer.McpAgent.SessionLog.ISessionLogWorkflow; +using IAgentTodoWorkflow = McpServer.McpAgent.Todo.ITodoWorkflow; +using IReplSessionLogWorkflow = McpServer.Repl.Core.ISessionLogWorkflow; namespace McpServer.McpAgent.Hosting; @@ -30,14 +32,20 @@ public sealed class McpHostedAgent : IMcpHostedAgent /// The configured scaffold options for the hosted agent. /// The session-log workflow service bound to this agent instance. /// The TODO workflow service bound to this agent instance. + /// The REPL-backed requirements workflow for FR/TR/TEST operations. + /// The generic client passthrough for dynamic sub-client method invocation. + /// The REPL-backed session-log workflow for history queries. /// The service provider used to create Agent Framework wrappers around the workflows. public McpHostedAgent( McpServerClient client, IMcpSessionIdentifierFactory identifiers, ChatClientAgentOptions agentOptions, IOptions options, - ISessionLogWorkflow sessionLog, - ITodoWorkflow todo, + IAgentSessionLogWorkflow sessionLog, + IAgentTodoWorkflow todo, + IRequirementsWorkflow requirements, + IGenericClientPassthrough clientPassthrough, + IReplSessionLogWorkflow replSessionLog, IServiceProvider serviceProvider) { Client = client ?? throw new ArgumentNullException(nameof(client)); @@ -52,7 +60,11 @@ public McpHostedAgent( _loggerFactory = ResolveLoggerFactory(); PowerShellSessions = new HostedPowerShellSessionManager(_loggerFactory.CreateLogger()); - var toolAdapter = new McpHostedAgentToolAdapter(Client, SessionLog, Todo, PowerShellSessions); + var toolAdapter = new McpHostedAgentToolAdapter( + Client, SessionLog, Todo, PowerShellSessions, + requirements ?? throw new ArgumentNullException(nameof(requirements)), + clientPassthrough ?? throw new ArgumentNullException(nameof(clientPassthrough)), + replSessionLog ?? throw new ArgumentNullException(nameof(replSessionLog))); var functions = toolAdapter.CreateFunctions(); Registration = new McpHostedAgentRegistration( _agentOptions, @@ -84,10 +96,10 @@ public McpHostedAgent( public McpServerClient Client { get; } /// - public ISessionLogWorkflow SessionLog { get; } + public IAgentSessionLogWorkflow SessionLog { get; } /// - public ITodoWorkflow Todo { get; } + public IAgentTodoWorkflow Todo { get; } /// public IHostedPowerShellSessionManager PowerShellSessions { get; } diff --git a/src/McpServer.McpAgent/Hosting/McpHostedAgentToolAdapter.cs b/src/McpServer.McpAgent/Hosting/McpHostedAgentToolAdapter.cs index 00ff87d..df38473 100644 --- a/src/McpServer.McpAgent/Hosting/McpHostedAgentToolAdapter.cs +++ b/src/McpServer.McpAgent/Hosting/McpHostedAgentToolAdapter.cs @@ -3,35 +3,50 @@ using McpServer.McpAgent.SessionLog; using McpServer.McpAgent.Todo; using McpServer.Client.Models; +using McpServer.Repl.Core; using Microsoft.Extensions.AI; +using IAgentSessionLogWorkflow = McpServer.McpAgent.SessionLog.ISessionLogWorkflow; +using IAgentTodoWorkflow = McpServer.McpAgent.Todo.ITodoWorkflow; +using IReplSessionLogWorkflow = McpServer.Repl.Core.ISessionLogWorkflow; namespace McpServer.McpAgent.Hosting; /// /// FR-MCP-066/TR-MCP-AGENT-007: Adapts hosted-agent tool definitions to the existing -/// session-log, TODO, repository, desktop-launch, and local PowerShell-session contracts. +/// session-log, TODO, repository, desktop-launch, local PowerShell-session contracts, +/// and REPL-based requirements, session history, and generic client passthrough operations. /// internal sealed class McpHostedAgentToolAdapter { private readonly McpServerClient _client; private readonly IHostedPowerShellSessionManager _powerShellSessions; - private readonly ISessionLogWorkflow _sessionLog; - private readonly ITodoWorkflow _todo; + private readonly IAgentSessionLogWorkflow _sessionLog; + private readonly IAgentTodoWorkflow _todo; + private readonly IRequirementsWorkflow _requirements; + private readonly IGenericClientPassthrough _clientPassthrough; + private readonly IReplSessionLogWorkflow _replSessionLog; public McpHostedAgentToolAdapter( McpServerClient client, - ISessionLogWorkflow sessionLog, - ITodoWorkflow todo, - IHostedPowerShellSessionManager powerShellSessions) + IAgentSessionLogWorkflow sessionLog, + IAgentTodoWorkflow todo, + IHostedPowerShellSessionManager powerShellSessions, + IRequirementsWorkflow requirements, + IGenericClientPassthrough clientPassthrough, + IReplSessionLogWorkflow replSessionLog) { _client = client ?? throw new ArgumentNullException(nameof(client)); _sessionLog = sessionLog ?? throw new ArgumentNullException(nameof(sessionLog)); _todo = todo ?? throw new ArgumentNullException(nameof(todo)); _powerShellSessions = powerShellSessions ?? throw new ArgumentNullException(nameof(powerShellSessions)); + _requirements = requirements ?? throw new ArgumentNullException(nameof(requirements)); + _clientPassthrough = clientPassthrough ?? throw new ArgumentNullException(nameof(clientPassthrough)); + _replSessionLog = replSessionLog ?? throw new ArgumentNullException(nameof(replSessionLog)); } public IReadOnlyList CreateFunctions() => [ + // ── Session log tools ────────────────────────────────────────── CreateTool( (Func>)BootstrapSessionAsync, "mcp_session_bootstrap", @@ -52,6 +67,12 @@ public IReadOnlyList CreateFunctions() => (Func>)CompleteSessionTurnAsync, "mcp_session_turn_complete", "Complete an MCP session-log turn by submitting a SessionLogTurnCompleteRequest payload."), + CreateTool( + (Func>>)QuerySessionHistoryAsync, + "mcp_session_query_history", + "Query session log history with optional agent filter, limit, and offset for pagination."), + + // ── TODO tools ───────────────────────────────────────────────── CreateTool( (Func>)QueryTodosAsync, "mcp_todo_query", @@ -64,6 +85,14 @@ public IReadOnlyList CreateFunctions() => (Func>)UpdateTodoAsync, "mcp_todo_update", "Update an MCP TODO item by identifier using a TodoUpdateRequest payload."), + CreateTool( + (Func>)CreateTodoAsync, + "mcp_todo_create", + "Create a new MCP TODO item with id, title, section, priority, and optional estimate/note/description fields."), + CreateTool( + (Func>)DeleteTodoAsync, + "mcp_todo_delete", + "Delete an MCP TODO item by its identifier."), CreateTool( (Func>)GetTodoPlanAsync, "mcp_todo_plan", @@ -76,6 +105,8 @@ public IReadOnlyList CreateFunctions() => (Func>)GetTodoImplementationGuideAsync, "mcp_todo_implementation", "Get the buffered MCP TODO implementation guide text for a TODO item identifier."), + + // ── Repository tools ─────────────────────────────────────────── CreateTool( (Func>)ReadRepoFileAsync, "mcp_repo_read", @@ -88,10 +119,14 @@ public IReadOnlyList CreateFunctions() => (Func>)WriteRepoFileAsync, "mcp_repo_write", "Write repository file content by relative path from the workspace root."), + + // ── Desktop tools ────────────────────────────────────────────── CreateTool( (Func?, bool, string, bool, int?, CancellationToken, Task>)LaunchDesktopProcessAsync, "mcp_desktop_launch", "Launch a local desktop process through the MCP server for the current workspace."), + + // ── PowerShell session tools ─────────────────────────────────── CreateTool( (Func>)CreatePowerShellSessionAsync, "mcp_powershell_session_create", @@ -104,6 +139,38 @@ public IReadOnlyList CreateFunctions() => (Func>)ClosePowerShellSessionAsync, "mcp_powershell_session_close", "Close a previously created in-process PowerShell session and release its resources."), + + // ── Requirements tools (REPL-backed) ─────────────────────────── + CreateTool( + (Func>)ListFunctionalRequirementsAsync, + "mcp_requirements_list_fr", + "List functional requirements with optional area and status filters."), + CreateTool( + (Func>)ListTechnicalRequirementsAsync, + "mcp_requirements_list_tr", + "List technical requirements with optional area, subarea, and status filters."), + CreateTool( + (Func>)ListTestRequirementsAsync, + "mcp_requirements_list_test", + "List test requirements with optional area and status filters."), + CreateTool( + (Func>)GetFunctionalRequirementAsync, + "mcp_requirements_get_fr", + "Get a specific functional requirement by its canonical identifier (e.g. FR-MCP-001)."), + CreateTool( + (Func>)GetTechnicalRequirementAsync, + "mcp_requirements_get_tr", + "Get a specific technical requirement by its canonical identifier (e.g. TR-MCP-ARCH-001)."), + CreateTool( + (Func>)GetTestRequirementAsync, + "mcp_requirements_get_test", + "Get a specific test requirement by its canonical identifier (e.g. TEST-MCP-001)."), + + // ── Generic client passthrough (REPL-backed) ─────────────────── + CreateTool( + (Func, CancellationToken, Task>)InvokeClientAsync, + "mcp_client_invoke", + "Dynamically invoke any MCP Server sub-client method by specifying clientName (e.g. 'context', 'github', 'workspace'), methodName (e.g. 'SearchAsync'), and a dictionary of arguments."), ]; private static AIFunction CreateTool(Delegate implementation, string name, string description) => @@ -115,6 +182,8 @@ private static AIFunction CreateTool(Delegate implementation, string name, strin Name = name, }); + // ── Session log implementations ──────────────────────────────────── + private Task BootstrapSessionAsync( SessionLogBootstrapRequest request, CancellationToken cancellationToken) => @@ -140,6 +209,15 @@ private Task CompleteSessionTurnAsync( CancellationToken cancellationToken) => _sessionLog.CompleteTurnAsync(request ?? throw new ArgumentNullException(nameof(request)), cancellationToken); + private Task> QuerySessionHistoryAsync( + string? agent, + int limit = 10, + int offset = 0, + CancellationToken cancellationToken = default) => + _replSessionLog.QueryHistoryAsync(agent, limit, offset, cancellationToken); + + // ── TODO implementations ─────────────────────────────────────────── + private Task QueryTodosAsync( string? keyword, string? priority, @@ -161,6 +239,18 @@ private Task UpdateTodoAsync( request ?? throw new ArgumentNullException(nameof(request)), cancellationToken); + private Task CreateTodoAsync( + TodoCreateRequest request, + CancellationToken cancellationToken) => + _client.Todo.CreateAsync( + request ?? throw new ArgumentNullException(nameof(request)), + cancellationToken); + + private Task DeleteTodoAsync( + string id, + CancellationToken cancellationToken) => + _client.Todo.DeleteAsync(id, cancellationToken); + private Task GetTodoPlanAsync(string id, CancellationToken cancellationToken) => _todo.GetPlanAsync(id, cancellationToken); @@ -170,6 +260,8 @@ private Task GetTodoStatusAsync(string id, CancellationToken cancellatio private Task GetTodoImplementationGuideAsync(string id, CancellationToken cancellationToken) => _todo.GetImplementationGuideAsync(id, cancellationToken); + // ── Repository implementations ───────────────────────────────────── + private Task ReadRepoFileAsync(string path, CancellationToken cancellationToken) => _client.Repo.ReadFileAsync(path, cancellationToken); @@ -182,6 +274,8 @@ private Task WriteRepoFileAsync( CancellationToken cancellationToken) => _client.Repo.WriteFileAsync(path, content, cancellationToken); + // ── Desktop implementations ──────────────────────────────────────── + private Task LaunchDesktopProcessAsync( string executablePath, string? arguments = null, @@ -206,6 +300,8 @@ private Task LaunchDesktopProcessAsync( }, cancellationToken); + // ── PowerShell session implementations ───────────────────────────── + private Task CreatePowerShellSessionAsync( string? workingDirectory = null, CancellationToken cancellationToken = default) @@ -227,4 +323,49 @@ private Task ClosePowerShellSessionAsync( cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(_powerShellSessions.CloseSession(sessionId)); } + + // ── Requirements implementations (REPL-backed) ───────────────────── + + private Task ListFunctionalRequirementsAsync( + string? area, + string? status, + CancellationToken cancellationToken) => + _requirements.ListFrAsync(area, status, cancellationToken); + + private Task ListTechnicalRequirementsAsync( + string? area, + string? subarea, + string? status, + CancellationToken cancellationToken) => + _requirements.ListTrAsync(area, subarea, status, cancellationToken); + + private Task ListTestRequirementsAsync( + string? area, + string? status, + CancellationToken cancellationToken) => + _requirements.ListTestAsync(area, status, cancellationToken); + + private Task GetFunctionalRequirementAsync( + string id, + CancellationToken cancellationToken) => + _requirements.GetFrAsync(id, cancellationToken); + + private Task GetTechnicalRequirementAsync( + string id, + CancellationToken cancellationToken) => + _requirements.GetTrAsync(id, cancellationToken); + + private Task GetTestRequirementAsync( + string id, + CancellationToken cancellationToken) => + _requirements.GetTestAsync(id, cancellationToken); + + // ── Generic client passthrough (REPL-backed) ─────────────────────── + + private Task InvokeClientAsync( + string clientName, + string methodName, + Dictionary arguments, + CancellationToken cancellationToken) => + _clientPassthrough.InvokeAsync(clientName, methodName, arguments, cancellationToken); } diff --git a/src/McpServer.McpAgent/McpServer.McpAgent.csproj b/src/McpServer.McpAgent/McpServer.McpAgent.csproj index 44da410..1bdfca0 100644 --- a/src/McpServer.McpAgent/McpServer.McpAgent.csproj +++ b/src/McpServer.McpAgent/McpServer.McpAgent.csproj @@ -23,6 +23,7 @@ + diff --git a/src/McpServer.McpAgent/ServiceCollectionExtensions.cs b/src/McpServer.McpAgent/ServiceCollectionExtensions.cs index 4af6239..1692c0a 100644 --- a/src/McpServer.McpAgent/ServiceCollectionExtensions.cs +++ b/src/McpServer.McpAgent/ServiceCollectionExtensions.cs @@ -1,17 +1,23 @@ using McpServer.McpAgent.Hosting; -using McpServer.McpAgent.SessionLog; -using McpServer.McpAgent.Todo; using McpServer.Client; +using McpServer.Repl.Core; using Microsoft.Agents.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using AgentSessionLogWorkflow = McpServer.McpAgent.SessionLog.SessionLogWorkflow; +using AgentTodoWorkflow = McpServer.McpAgent.Todo.TodoWorkflow; +using IAgentSessionLogWorkflow = McpServer.McpAgent.SessionLog.ISessionLogWorkflow; +using IAgentTodoWorkflow = McpServer.McpAgent.Todo.ITodoWorkflow; +using IReplSessionLogWorkflow = McpServer.Repl.Core.ISessionLogWorkflow; +using ReplSessionLogWorkflow = McpServer.Repl.Core.SessionLogWorkflow; namespace McpServer.McpAgent; /// /// FR-MCP-066/TR-MCP-AGENT-006: Dependency injection extensions for the hosted MCP Agent -/// registration surface, including the built-in session-log and TODO workflow services. +/// registration surface, including the built-in session-log, TODO, requirements, and +/// generic client passthrough workflow services. /// public static class ServiceCollectionExtensions { @@ -56,8 +62,25 @@ public static IServiceCollection AddMcpServerMcpAgent(this IServiceCollection se services.TryAddTransient(static serviceProvider => serviceProvider.GetRequiredService>().Value); - services.TryAddTransient(); - services.TryAddTransient(); + // McpAgent-internal workflows (operate on McpAgent.SessionLog/Todo types) + services.TryAddTransient(); + services.TryAddTransient(); + + // REPL Core workflows (requirements, session history, generic passthrough) + services.TryAddTransient(static sp => + new RequirementsWorkflow(sp.GetRequiredService().Requirements)); + + services.TryAddTransient(static sp => + new GenericClientPassthrough(sp.GetRequiredService())); + + services.TryAddTransient(static sp => + new SessionLogClientAdapter(sp.GetRequiredService().SessionLog)); + + services.TryAddTransient(static sp => + new ReplSessionLogWorkflow( + sp.GetRequiredService(), + sp.GetRequiredService())); + services.TryAddTransient(); services.TryAddSingleton(); return services; diff --git a/src/McpServer.Repl.Core/SessionLogWorkflow.cs b/src/McpServer.Repl.Core/SessionLogWorkflow.cs index 2e38275..8471533 100644 --- a/src/McpServer.Repl.Core/SessionLogWorkflow.cs +++ b/src/McpServer.Repl.Core/SessionLogWorkflow.cs @@ -682,20 +682,26 @@ Task AppendDialogAsync( /// /// Production adapter for SessionLogClient. /// -internal sealed class SessionLogClientAdapter : ISessionLogClientAdapter +public sealed class SessionLogClientAdapter : ISessionLogClientAdapter { private readonly SessionLogClient _client; + /// + /// Initializes a new wrapping the specified client. + /// + /// The session log client to wrap. public SessionLogClientAdapter(SessionLogClient client) { _client = client ?? throw new ArgumentNullException(nameof(client)); } + /// public Task SubmitAsync(UnifiedSessionLogDto sessionLog, CancellationToken cancellationToken = default) { return _client.SubmitAsync(sessionLog, cancellationToken); } + /// public Task QueryAsync( string? agent = null, string? model = null, @@ -709,6 +715,7 @@ public Task QueryAsync( return _client.QueryAsync(agent, model, text, from, to, limit, offset, cancellationToken); } + /// public Task AppendDialogAsync( string agent, string sessionId, diff --git a/src/McpServer.Repl.Host/InteractiveHandler.cs b/src/McpServer.Repl.Host/InteractiveHandler.cs index ae0dab1..9bc87ec 100644 --- a/src/McpServer.Repl.Host/InteractiveHandler.cs +++ b/src/McpServer.Repl.Host/InteractiveHandler.cs @@ -17,6 +17,7 @@ public class InteractiveHandler { private readonly ILogger _logger; private readonly McpServerClient _client; + private readonly LoginHandler _loginHandler; private string? _currentWorkspace; /// @@ -24,12 +25,15 @@ public class InteractiveHandler /// /// Logger instance for diagnostic output. /// MCP server client. + /// Login handler for OIDC authentication. public InteractiveHandler( ILogger logger, - McpServerClient client) + McpServerClient client, + LoginHandler loginHandler) { _logger = logger; _client = client; + _loginHandler = loginHandler; } /// @@ -49,25 +53,54 @@ public async Task RunAsync(CancellationToken cancellationToken) AnsiConsole.MarkupLine("[dim]Model Context Protocol - Interactive Mode[/]"); AnsiConsole.WriteLine(); + // Attempt login before workspace selection if no valid token is cached + if (!_loginHandler.IsLoggedIn) + { + await _loginHandler.LoginAsync(cancellationToken); + AnsiConsole.WriteLine(); + } + else + { + AnsiConsole.MarkupLine($"[green]Authenticated as [bold]{Markup.Escape(_loginHandler.CurrentUser ?? "cached")}[/][/]"); + AnsiConsole.WriteLine(); + } + await SelectWorkspaceAsync(cancellationToken); while (!cancellationToken.IsCancellationRequested) { try { + // Refresh token if expired before showing the menu + if (_loginHandler.IsLoggedIn || !string.IsNullOrWhiteSpace(_loginHandler.CurrentUser)) + await _loginHandler.EnsureAuthenticatedAsync(cancellationToken); + + var timeRemaining = _loginHandler.TokenTimeRemaining; + var tokenInfo = timeRemaining.HasValue ? $" [dim]({timeRemaining.Value.Minutes}m {timeRemaining.Value.Seconds}s)[/]" : ""; + var authStatus = _loginHandler.IsLoggedIn + ? $"[cyan]{Markup.Escape(_loginHandler.CurrentUser ?? "authenticated")}[/]{tokenInfo}" + : "[dim]not logged in[/]"; + + var menuChoices = new List + { + "Bootstrap Session", + "Begin Turn", + "List TODOs", + "Create TODO", + "Update TODO", + "Ingest Requirements", + "List Requirements", + "Switch Workspace", + }; + + menuChoices.Add(_loginHandler.IsLoggedIn ? "Logout" : "Login"); + menuChoices.Add("Exit"); + var action = AnsiConsole.Prompt( new SelectionPrompt() - .Title($"[green]Workspace:[/] [yellow]{_currentWorkspace ?? "none"}[/]\n[green]Select an action:[/]") + .Title($"[green]Workspace:[/] [yellow]{_currentWorkspace ?? "none"}[/] | [green]User:[/] {authStatus}\n[green]Select an action:[/]") .PageSize(10) - .AddChoices(new[] - { - "Bootstrap Session", - "Begin Turn", - "Create TODO", - "List Requirements", - "Switch Workspace", - "Exit" - })); + .AddChoices(menuChoices)); switch (action) { @@ -77,15 +110,30 @@ public async Task RunAsync(CancellationToken cancellationToken) case "Begin Turn": await BeginTurnAsync(cancellationToken); break; + case "List TODOs": + await ListTodosAsync(cancellationToken); + break; case "Create TODO": await CreateTodoAsync(cancellationToken); break; + case "Update TODO": + await UpdateTodoAsync(cancellationToken); + break; + case "Ingest Requirements": + await IngestRequirementsAsync(cancellationToken); + break; case "List Requirements": await ListRequirementsAsync(cancellationToken); break; case "Switch Workspace": await SelectWorkspaceAsync(cancellationToken); break; + case "Login": + await _loginHandler.ManualLoginMenuAsync(null, cancellationToken); + break; + case "Logout": + _loginHandler.Logout(); + break; case "Exit": AnsiConsole.MarkupLine("[yellow]Goodbye![/]"); return; @@ -145,12 +193,11 @@ private async Task BootstrapSessionAsync(CancellationToken cancellationToken) AnsiConsole.WriteLine(); var agent = AnsiConsole.Ask("Agent name:", "Tonkotsu"); - var sessionId = AnsiConsole.Ask("Session ID (leave empty for auto):", string.Empty); - - if (string.IsNullOrWhiteSpace(sessionId)) - { - sessionId = $"session-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}"; - } + var suffix = AnsiConsole.Ask("Session suffix (e.g., feature-auth):", "dev-session"); + var timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMddTHHmmssZ"); + var sessionId = $"{agent}-{timestamp}-{suffix}"; + + AnsiConsole.MarkupLine($"[dim]Session ID: {Markup.Escape(sessionId)}[/]"); var model = AnsiConsole.Ask("Model:", "claude-3-5-sonnet-20241022"); var purpose = AnsiConsole.Ask("Purpose:", "Development session"); @@ -168,7 +215,7 @@ private async Task BootstrapSessionAsync(CancellationToken cancellationToken) { new UnifiedRequestEntryDto { - RequestId = $"req-{now:yyyyMMdd-HHmmss}", + RequestId = $"req-{now:yyyyMMddTHHmmssZ}-bootstrap-001", Timestamp = now.ToString("o"), Interpretation = "Session bootstrap", Response = purpose, @@ -223,12 +270,8 @@ private async Task BeginTurnAsync(CancellationToken cancellationToken) var agent = AnsiConsole.Ask("Agent name:", "Tonkotsu"); var sessionId = AnsiConsole.Ask("Session ID:"); - var requestId = AnsiConsole.Ask("Request ID (leave empty for auto):", string.Empty); - - if (string.IsNullOrWhiteSpace(requestId)) - { - requestId = $"req-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}"; - } + var turnSlug = AnsiConsole.Ask("Turn slug (e.g., implement-auth):", "turn-001"); + var requestId = $"req-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssZ}-{turnSlug}"; var interpretation = AnsiConsole.Ask("Interpretation:", "User request"); var response = AnsiConsole.Ask("Response:", "Processing..."); @@ -286,6 +329,229 @@ await AnsiConsole.Status() } } + private async Task ListTodosAsync(CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(_currentWorkspace)) + { + AnsiConsole.MarkupLine("[red]No workspace selected[/]"); + return; + } + + var filterAction = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("[green]Filter TODOs:[/]") + .AddChoices("All", "Not Done", "Done", "By Priority", "By Keyword")); + + string? keyword = null, priority = null; + bool? done = null; + + switch (filterAction) + { + case "Not Done": + done = false; + break; + case "Done": + done = true; + break; + case "By Priority": + priority = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Priority:") + .AddChoices("P0-Critical", "P1-High", "P2-Medium", "P3-Low")); + break; + case "By Keyword": + keyword = AnsiConsole.Ask("Search keyword:"); + break; + } + + try + { + await AnsiConsole.Status() + .StartAsync("Fetching TODOs...", async ctx => + { + var result = await _client.Todo.QueryAsync( + keyword: keyword, priority: priority, done: done, + cancellationToken: cancellationToken); + + if (result?.Items == null || result.Items.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No TODOs found matching the filter.[/]"); + return; + } + + var table = new Table(); + table.Border(TableBorder.Rounded); + table.AddColumn("[green]ID[/]"); + table.AddColumn("[green]Title[/]"); + table.AddColumn("[green]Priority[/]"); + table.AddColumn("[green]Section[/]"); + table.AddColumn("[green]Done[/]"); + + foreach (var item in result.Items) + { + var doneText = item.Done ? "[green]Yes[/]" : "[dim]No[/]"; + var priorityColor = item.Priority switch + { + "P0-Critical" => "red", + "P1-High" => "yellow", + "P2-Medium" => "blue", + _ => "dim" + }; + + table.AddRow( + Markup.Escape(item.Id), + Markup.Escape(item.Title), + $"[{priorityColor}]{Markup.Escape(item.Priority)}[/]", + Markup.Escape(item.Section), + doneText); + } + + AnsiConsole.Write(table); + AnsiConsole.MarkupLine($"\n[dim]Total: {result.Items.Count} TODO(s)[/]"); + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to list TODOs"); + AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); + } + } + + private async Task UpdateTodoAsync(CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(_currentWorkspace)) + { + AnsiConsole.MarkupLine("[red]No workspace selected[/]"); + return; + } + + // Fetch all TODOs so the user can pick one + TodoQueryResult? queryResult = null; + try + { + await AnsiConsole.Status() + .StartAsync("Fetching TODOs...", async ctx => + { + queryResult = await _client.Todo.QueryAsync(cancellationToken: cancellationToken); + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch TODOs"); + AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); + return; + } + + if (queryResult?.Items == null || queryResult.Items.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No TODOs found.[/]"); + return; + } + + var selectedDisplay = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("[green]Select a TODO to update:[/]") + .PageSize(15) + .AddChoices(queryResult.Items.Select(i => + $"{i.Id} — {i.Title} [{(i.Done ? "Done" : i.Priority)}]"))); + + var selectedId = selectedDisplay.Split(" — ")[0]; + var selectedItem = queryResult.Items.FirstOrDefault(i => i.Id == selectedId); + if (selectedItem is null) + { + AnsiConsole.MarkupLine("[red]Could not find selected TODO.[/]"); + return; + } + + // Show current state + AnsiConsole.MarkupLine($"[bold blue]Updating:[/] {Markup.Escape(selectedItem.Id)} — {Markup.Escape(selectedItem.Title)}"); + AnsiConsole.MarkupLine($" [dim]Priority:[/] {Markup.Escape(selectedItem.Priority)} [dim]Section:[/] {Markup.Escape(selectedItem.Section)} [dim]Done:[/] {selectedItem.Done}"); + AnsiConsole.WriteLine(); + + var fieldToUpdate = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("[green]What to update:[/]") + .AddChoices("Toggle Done", "Change Priority", "Change Title", "Change Section", "Add Note", "Set Estimate", "Cancel")); + + if (fieldToUpdate == "Cancel") + return; + + var request = new TodoUpdateRequest(); + + switch (fieldToUpdate) + { + case "Toggle Done": + request.Done = !selectedItem.Done; + if (request.Done == true) + { + var summary = AnsiConsole.Ask("Completion summary (optional):", ""); + if (!string.IsNullOrWhiteSpace(summary)) + request.DoneSummary = summary; + request.CompletedDate = DateTime.UtcNow.ToString("yyyy-MM-dd"); + } + break; + case "Change Priority": + request.Priority = AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"Current: [yellow]{Markup.Escape(selectedItem.Priority)}[/] → New priority:") + .AddChoices("P0-Critical", "P1-High", "P2-Medium", "P3-Low")); + break; + case "Change Title": + request.Title = AnsiConsole.Ask("New title:", selectedItem.Title); + break; + case "Change Section": + request.Section = AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"Current: [yellow]{Markup.Escape(selectedItem.Section)}[/] → New section:") + .AddChoices("Planning", "In-Progress", "Done", "Blocked")); + break; + case "Add Note": + request.Note = AnsiConsole.Ask("Note:"); + break; + case "Set Estimate": + request.Estimate = AnsiConsole.Ask("Estimate (e.g., 2h, 1d):"); + break; + } + + try + { + await AnsiConsole.Status() + .StartAsync("Updating TODO...", async ctx => + { + var result = await _client.Todo.UpdateAsync(selectedId, request, cancellationToken); + + if (result.Success && result.Item != null) + { + AnsiConsole.MarkupLine($"[green]✓[/] Updated: {Markup.Escape(result.Item.Id)}"); + + var table = new Table(); + table.AddColumn("Field"); + table.AddColumn("Value"); + table.AddRow("ID", Markup.Escape(result.Item.Id)); + table.AddRow("Title", Markup.Escape(result.Item.Title)); + table.AddRow("Section", Markup.Escape(result.Item.Section)); + table.AddRow("Priority", Markup.Escape(result.Item.Priority)); + table.AddRow("Done", result.Item.Done.ToString()); + if (!string.IsNullOrWhiteSpace(result.Item.Note)) + table.AddRow("Note", Markup.Escape(result.Item.Note)); + if (!string.IsNullOrWhiteSpace(result.Item.Estimate)) + table.AddRow("Estimate", Markup.Escape(result.Item.Estimate)); + + AnsiConsole.Write(table); + } + else + { + AnsiConsole.MarkupLine($"[red]✗[/] Failed to update TODO"); + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update TODO {TodoId}", selectedId); + AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); + } + } + private async Task CreateTodoAsync(CancellationToken cancellationToken) { if (string.IsNullOrEmpty(_currentWorkspace)) @@ -357,6 +623,283 @@ await AnsiConsole.Status() } } + private async Task IngestRequirementsAsync(CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(_currentWorkspace)) + { + AnsiConsole.MarkupLine("[red]No workspace selected[/]"); + return; + } + + AnsiConsole.MarkupLine("[bold blue]Ingest Requirements[/]"); + AnsiConsole.MarkupLine("[dim]Provide markdown file paths or paste content for each requirement type.[/]"); + AnsiConsole.MarkupLine("[dim]Leave blank to skip a type. The server parses markdown and upserts FR/TR/TEST/mapping entries.[/]"); + AnsiConsole.WriteLine(); + + var mode = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("[green]Ingest mode:[/]") + .AddChoices("From Files", "From Workspace Defaults", "Paste Markdown", "Cancel")); + + if (mode == "Cancel") + return; + + var request = new RequirementsIngestRequest(); + + if (mode == "From Workspace Defaults") + { + var basePath = _currentWorkspace; + var discovered = DiscoverRequirementsFiles(basePath); + + if (discovered.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No requirements files found in workspace.[/]"); + return; + } + + // Group discovered files by type + var frFiles = discovered.Where(d => d.Type == "functional").ToList(); + var trFiles = discovered.Where(d => d.Type == "technical").ToList(); + var testFiles = discovered.Where(d => d.Type == "testing").ToList(); + var mapFiles = discovered.Where(d => d.Type == "mapping").ToList(); + + // Show all discovered files + AnsiConsole.MarkupLine($"[bold]Discovered {discovered.Count} requirements file(s):[/]"); + foreach (var d in discovered) + { + var relPath = Path.GetRelativePath(basePath, d.FullPath); + AnsiConsole.MarkupLine($" [green]✓[/] [{d.TypeColor}]{Markup.Escape(d.TypeLabel)}[/] {Markup.Escape(relPath)}"); + } + AnsiConsole.WriteLine(); + + // Let user pick which files to ingest when there are multiple per type + request.FunctionalMarkdown = await SelectAndConcatFilesAsync(frFiles, "Functional (FR)", cancellationToken); + request.TechnicalMarkdown = await SelectAndConcatFilesAsync(trFiles, "Technical (TR)", cancellationToken); + request.TestingMarkdown = await SelectAndConcatFilesAsync(testFiles, "Testing (TEST)", cancellationToken); + request.MappingMarkdown = await SelectAndConcatFilesAsync(mapFiles, "Mapping", cancellationToken); + } + else if (mode == "From Files") + { + var frPath = AnsiConsole.Ask("Functional requirements file path (blank to skip):", ""); + var trPath = AnsiConsole.Ask("Technical requirements file path (blank to skip):", ""); + var testPath = AnsiConsole.Ask("Testing requirements file path (blank to skip):", ""); + var mapPath = AnsiConsole.Ask("FR-TR mapping file path (blank to skip):", ""); + + if (!string.IsNullOrWhiteSpace(frPath)) + { + if (!File.Exists(frPath)) { AnsiConsole.MarkupLine($"[red]File not found: {Markup.Escape(frPath)}[/]"); return; } + request.FunctionalMarkdown = await File.ReadAllTextAsync(frPath, cancellationToken); + } + if (!string.IsNullOrWhiteSpace(trPath)) + { + if (!File.Exists(trPath)) { AnsiConsole.MarkupLine($"[red]File not found: {Markup.Escape(trPath)}[/]"); return; } + request.TechnicalMarkdown = await File.ReadAllTextAsync(trPath, cancellationToken); + } + if (!string.IsNullOrWhiteSpace(testPath)) + { + if (!File.Exists(testPath)) { AnsiConsole.MarkupLine($"[red]File not found: {Markup.Escape(testPath)}[/]"); return; } + request.TestingMarkdown = await File.ReadAllTextAsync(testPath, cancellationToken); + } + if (!string.IsNullOrWhiteSpace(mapPath)) + { + if (!File.Exists(mapPath)) { AnsiConsole.MarkupLine($"[red]File not found: {Markup.Escape(mapPath)}[/]"); return; } + request.MappingMarkdown = await File.ReadAllTextAsync(mapPath, cancellationToken); + } + } + else // Paste Markdown + { + AnsiConsole.MarkupLine("[dim]Paste markdown for each type, then press Enter twice (blank line) to finish.[/]"); + AnsiConsole.WriteLine(); + + request.FunctionalMarkdown = ReadMultiline("Functional Requirements (FR)"); + request.TechnicalMarkdown = ReadMultiline("Technical Requirements (TR)"); + request.TestingMarkdown = ReadMultiline("Testing Requirements (TEST)"); + request.MappingMarkdown = ReadMultiline("FR-TR Mapping"); + } + + // Check we have at least something to ingest + if (string.IsNullOrWhiteSpace(request.FunctionalMarkdown) + && string.IsNullOrWhiteSpace(request.TechnicalMarkdown) + && string.IsNullOrWhiteSpace(request.TestingMarkdown) + && string.IsNullOrWhiteSpace(request.MappingMarkdown)) + { + AnsiConsole.MarkupLine("[yellow]No content provided. Nothing to ingest.[/]"); + return; + } + + try + { + await AnsiConsole.Status() + .StartAsync("Ingesting requirements...", async ctx => + { + var result = await _client.Requirements.IngestAsync(request, cancellationToken); + + AnsiConsole.MarkupLine("[green]✓ Requirements ingested successfully[/]"); + AnsiConsole.WriteLine(); + + var table = new Table(); + table.Border(TableBorder.Rounded); + table.AddColumn("[bold]Type[/]"); + table.AddColumn("[bold]Parsed[/]"); + table.AddColumn("[bold]Added[/]"); + table.AddColumn("[bold]Updated[/]"); + + table.AddRow("Functional (FR)", + result.FunctionalParsed.ToString(), + $"[green]{result.FunctionalAdded}[/]", + $"[yellow]{result.FunctionalUpdated}[/]"); + table.AddRow("Technical (TR)", + result.TechnicalParsed.ToString(), + $"[green]{result.TechnicalAdded}[/]", + $"[yellow]{result.TechnicalUpdated}[/]"); + table.AddRow("Testing (TEST)", + result.TestingParsed.ToString(), + $"[green]{result.TestingAdded}[/]", + $"[yellow]{result.TestingUpdated}[/]"); + table.AddRow("Mapping", + result.MappingParsed.ToString(), + $"[green]{result.MappingAdded}[/]", + $"[yellow]{result.MappingUpdated}[/]"); + + AnsiConsole.Write(table); + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to ingest requirements"); + AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); + } + } + + private static string? ReadMultiline(string label) + { + AnsiConsole.MarkupLine($"[bold]{Markup.Escape(label)}[/] [dim](blank line to finish, or just Enter to skip):[/]"); + var lines = new List(); + while (true) + { + var line = Console.ReadLine(); + if (line is null || line.Length == 0) + break; + lines.Add(line); + } + return lines.Count > 0 ? string.Join("\n", lines) : null; + } + + private record DiscoveredRequirementsFile(string FullPath, string Type, string TypeLabel, string TypeColor); + + private static List DiscoverRequirementsFiles(string workspacePath) + { + var results = new List(); + + // Search directories commonly used for requirements docs + var searchDirs = new[] + { + "docs/Project", "docs/project", + "docs/Requirements", "docs/requirements", + "docs", "requirements", "specs", + }; + + // Filename patterns → type classification + // Order matters: more specific patterns first + var patterns = new (string[] FilePatterns, string Type, string Label, string Color)[] + { + (new[] { "Functional-Requirements", "functional-requirements", "FR.md", "functional.md", "Requirements-FR" }, + "functional", "FR", "green"), + (new[] { "Technical-Requirements", "technical-requirements", "TR.md", "technical.md", "Requirements-TR" }, + "technical", "TR", "blue"), + (new[] { "Testing-Requirements", "testing-requirements", "TEST.md", "testing.md", "Requirements-TEST" }, + "testing", "TEST", "yellow"), + (new[] { "TR-per-FR-Mapping", "FR-TR-mapping", "mapping.md", "Requirements-Mapping", "traceability" }, + "mapping", "Mapping", "cyan"), + }; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var dir in searchDirs) + { + var fullDir = Path.Combine(workspacePath, dir); + if (!Directory.Exists(fullDir)) + continue; + + foreach (var file in Directory.EnumerateFiles(fullDir, "*.md")) + { + var fileName = Path.GetFileName(file); + if (seen.Contains(file)) + continue; + + // Check known patterns first + var matched = false; + foreach (var (filePatterns, type, label, color) in patterns) + { + if (filePatterns.Any(p => fileName.Contains(p, StringComparison.OrdinalIgnoreCase))) + { + results.Add(new DiscoveredRequirementsFile(file, type, label, color)); + seen.Add(file); + matched = true; + break; + } + } + + // Also pick up domain-specific requirements files (e.g. Requirements-WebUI.md, Requirements-Director.md) + if (!matched && fileName.StartsWith("Requirements-", StringComparison.OrdinalIgnoreCase)) + { + // Domain-specific requirements default to functional + results.Add(new DiscoveredRequirementsFile(file, "functional", "FR (domain)", "green")); + seen.Add(file); + } + + // Also match REPL-Requirements-Summary.md style + if (!matched && !seen.Contains(file) + && fileName.Contains("Requirements", StringComparison.OrdinalIgnoreCase) + && fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + { + results.Add(new DiscoveredRequirementsFile(file, "functional", "FR (misc)", "green")); + seen.Add(file); + } + } + } + + return results; + } + + private static async Task SelectAndConcatFilesAsync( + List files, + string typeLabel, + CancellationToken cancellationToken) + { + if (files.Count == 0) + return null; + + IEnumerable selected; + + if (files.Count == 1) + { + selected = files; + } + else + { + // Let user multi-select which files to include + var choices = files.Select(f => Path.GetFileName(f.FullPath)).ToList(); + var picked = AnsiConsole.Prompt( + new MultiSelectionPrompt() + .Title($"[green]Select {Markup.Escape(typeLabel)} files to ingest:[/]") + .PageSize(10) + .AddChoices(choices) + .InstructionsText("[dim](Space to toggle, Enter to confirm)[/]")); + + selected = files.Where(f => picked.Contains(Path.GetFileName(f.FullPath))); + } + + var parts = new List(); + foreach (var file in selected) + { + var content = await File.ReadAllTextAsync(file.FullPath, cancellationToken); + parts.Add(content); + } + + return parts.Count > 0 ? string.Join("\n\n---\n\n", parts) : null; + } + private async Task ListRequirementsAsync(CancellationToken cancellationToken) { if (string.IsNullOrEmpty(_currentWorkspace)) @@ -417,9 +960,9 @@ private void DisplayFunctionalRequirements(IReadOnlyList frs) { var body = fr.Body ?? ""; table.AddRow( - fr.Id ?? "", - fr.Title ?? "", - body.Length > 50 ? body.Substring(0, 50) + "..." : body); + Markup.Escape(fr.Id ?? ""), + Markup.Escape(fr.Title ?? ""), + Markup.Escape(body.Length > 50 ? body.Substring(0, 50) + "..." : body)); } AnsiConsole.Write(table); @@ -444,9 +987,9 @@ private void DisplayTechnicalRequirements(IReadOnlyList trs) { var body = tr.Body ?? ""; table.AddRow( - tr.Id ?? "", - tr.Title ?? "", - body.Length > 50 ? body.Substring(0, 50) + "..." : body); + Markup.Escape(tr.Id ?? ""), + Markup.Escape(tr.Title ?? ""), + Markup.Escape(body.Length > 50 ? body.Substring(0, 50) + "..." : body)); } AnsiConsole.Write(table); @@ -470,8 +1013,8 @@ private void DisplayTestingRequirements(IReadOnlyList tests) { var condition = test.Condition ?? ""; table.AddRow( - test.Id ?? "", - condition.Length > 80 ? condition.Substring(0, 80) + "..." : condition); + Markup.Escape(test.Id ?? ""), + Markup.Escape(condition.Length > 80 ? condition.Substring(0, 80) + "..." : condition)); } AnsiConsole.Write(table); diff --git a/src/McpServer.Repl.Host/LoginHandler.cs b/src/McpServer.Repl.Host/LoginHandler.cs new file mode 100644 index 0000000..65daac5 --- /dev/null +++ b/src/McpServer.Repl.Host/LoginHandler.cs @@ -0,0 +1,729 @@ +using System.Diagnostics; +using System.IdentityModel.Tokens.Jwt; +using System.Text.Json; +using System.Text.Json.Serialization; +using IdentityModel.Client; +using McpServer.Client; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace McpServer.Repl.Host; + +/// +/// Handles interactive login against the MCP Server's OIDC authority. +/// Persists tokens to ~/.mcpserver/tokens.json (shared with Director). +/// On startup, loads cached token; if expired, refreshes; if no token, auto-starts device flow. +/// Falls back to manual login menu only when device flow fails. +/// +public class LoginHandler +{ + private readonly ILogger _logger; + private readonly McpServerClient _client; + + // File-based token cache (shared with Director at ~/.mcpserver/tokens.json) + private static readonly string s_cacheDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".mcpserver"); + private static readonly string s_cachePath = Path.Combine(s_cacheDir, "tokens.json"); + + // In-memory cached credentials for automatic token refresh within session + private string? _cachedTokenEndpoint; + private string? _cachedClientId; + private string? _cachedScopes; + private string? _cachedUsername; + private string? _cachedPassword; + private string? _cachedClientSecret; + private string? _cachedRefreshToken; + private DateTimeOffset _tokenExpiresAt = DateTimeOffset.MinValue; + private bool _isClientCredentials; + + /// Initializes a new instance of the class. + public LoginHandler(ILogger logger, McpServerClient client) + { + _logger = logger; + _client = client; + } + + /// Gets the current username if logged in, or null. + public string? CurrentUser { get; private set; } + + /// Gets whether the user is currently authenticated with a non-expired bearer token. + public bool IsLoggedIn => !string.IsNullOrWhiteSpace(_client.BearerToken) && !IsTokenExpired; + + /// Gets whether the cached token has expired (with a 30-second buffer). + private bool IsTokenExpired => _tokenExpiresAt <= DateTimeOffset.UtcNow.AddSeconds(30); + + /// Gets the time remaining on the current token, or null if not logged in. + public TimeSpan? TokenTimeRemaining => + _tokenExpiresAt > DateTimeOffset.UtcNow ? _tokenExpiresAt - DateTimeOffset.UtcNow : null; + + // ── File-based token cache ────────────────────────────────────────── + + private void SaveTokenToFile() + { + try + { + Directory.CreateDirectory(s_cacheDir); + var cached = new CachedToken + { + AccessToken = _client.BearerToken ?? "", + RefreshToken = _cachedRefreshToken ?? "", + ExpiresAtUtc = _tokenExpiresAt.UtcDateTime, + Authority = "", + TokenEndpoint = _cachedTokenEndpoint ?? "", + ClientId = _cachedClientId ?? "mcp-director", + }; + var json = JsonSerializer.Serialize(cached, s_cacheJsonOpts); + File.WriteAllText(s_cachePath, json); + _logger.LogDebug("Token saved to {Path}", s_cachePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to save token cache to {Path}", s_cachePath); + } + } + + private CachedToken? LoadTokenFromFile() + { + if (!File.Exists(s_cachePath)) + return null; + + try + { + var json = File.ReadAllText(s_cachePath); + return JsonSerializer.Deserialize(json, s_cacheJsonOpts); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load token cache from {Path}", s_cachePath); + return null; + } + } + + private void ClearTokenFile() + { + try + { + if (File.Exists(s_cachePath)) + File.Delete(s_cachePath); + } + catch { /* best effort */ } + } + + /// + /// Tries to restore a session from the file-based token cache. + /// Returns true if a valid (or refreshed) token was loaded. + /// + public async Task TryRestoreSessionAsync(CancellationToken cancellationToken) + { + var cached = LoadTokenFromFile(); + if (cached is null || string.IsNullOrWhiteSpace(cached.AccessToken)) + return false; + + _client.BearerToken = cached.AccessToken; + _tokenExpiresAt = new DateTimeOffset(cached.ExpiresAtUtc, TimeSpan.Zero); + _cachedRefreshToken = cached.RefreshToken; + _cachedTokenEndpoint = cached.TokenEndpoint; + _cachedClientId = cached.ClientId; + + // Extract username from cached JWT + CurrentUser = ExtractUsernameFromJwt(cached.AccessToken); + + if (!IsTokenExpired) + { + AnsiConsole.MarkupLine($"[green]Restored session for [bold]{Markup.Escape(CurrentUser ?? "authenticated")}[/][/]"); + AnsiConsole.MarkupLine($"[dim]Token expires at {_tokenExpiresAt.LocalDateTime:HH:mm:ss} ({TokenTimeRemaining?.TotalMinutes:F0}m remaining)[/]"); + return true; + } + + // Token expired — try refresh + _logger.LogInformation("Cached token expired, attempting refresh"); + AnsiConsole.MarkupLine("[yellow]Cached token expired, refreshing...[/]"); + + if (await TryRefreshTokenAsync(cancellationToken)) + { + SaveTokenToFile(); + return true; + } + + // Refresh failed — clear stale cache + _client.BearerToken = ""; + CurrentUser = null; + _tokenExpiresAt = DateTimeOffset.MinValue; + return false; + } + + /// + /// Ensures the bearer token is still valid. If expired, attempts automatic refresh. + /// Call this before making API requests. + /// + public async Task EnsureAuthenticatedAsync(CancellationToken cancellationToken) + { + if (!IsLoggedIn && !string.IsNullOrWhiteSpace(_client.BearerToken)) + { + _logger.LogInformation("Bearer token expired, attempting refresh"); + AnsiConsole.MarkupLine("[yellow]Token expired, refreshing...[/]"); + + if (await TryRefreshTokenAsync(cancellationToken)) + { + SaveTokenToFile(); + return true; + } + + AnsiConsole.MarkupLine("[yellow]Token refresh failed. Please log in again.[/]"); + return false; + } + + return IsLoggedIn; + } + + /// + /// Runs the automatic login flow: + /// 1. Try cached token from file + /// 2. Auto-start device flow if available + /// 3. Fall back to manual login menu only if device flow fails + /// + public async Task LoginAsync(CancellationToken cancellationToken) + { + // Step 1: Try cached token + if (await TryRestoreSessionAsync(cancellationToken)) + return true; + + // Step 2: Discover auth config + AnsiConsole.MarkupLine("[blue]Discovering auth configuration...[/]"); + + Client.Models.AuthConfigResponse authConfig; + try + { + authConfig = await _client.AuthConfig.GetConfigAsync(cancellationToken); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Failed to fetch auth config: {Markup.Escape(ex.Message)}[/]"); + _logger.LogWarning(ex, "Failed to fetch auth config"); + return false; + } + + if (!authConfig.Enabled) + { + AnsiConsole.MarkupLine("[yellow]Authentication is not enabled on this server.[/]"); + AnsiConsole.MarkupLine("[dim]Continuing without authentication.[/]"); + return false; + } + + AnsiConsole.MarkupLine($"[green]Authority:[/] {Markup.Escape(authConfig.Authority ?? "")}"); + AnsiConsole.WriteLine(); + + // Step 3: Auto-start device flow if available + if (!string.IsNullOrWhiteSpace(authConfig.DeviceAuthorizationEndpoint)) + { + AnsiConsole.MarkupLine("[blue]Starting device authorization flow...[/]"); + if (await DeviceFlowLoginAsync(authConfig, cancellationToken)) + return true; + + // Device flow failed — fall through to manual menu + AnsiConsole.MarkupLine("[yellow]Device flow did not complete. Select an alternative login method.[/]"); + AnsiConsole.WriteLine(); + } + + // Step 4: Manual login menu (fallback) + return await ManualLoginMenuAsync(authConfig, cancellationToken); + } + + /// + /// Shows the manual login method selection menu. + /// Called when device flow fails or is unavailable. + /// + public async Task ManualLoginMenuAsync( + Client.Models.AuthConfigResponse? authConfig, + CancellationToken cancellationToken) + { + if (authConfig is null) + { + try + { + authConfig = await _client.AuthConfig.GetConfigAsync(cancellationToken); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Failed to fetch auth config: {Markup.Escape(ex.Message)}[/]"); + return false; + } + + if (!authConfig.Enabled) + { + AnsiConsole.MarkupLine("[yellow]Authentication is not enabled on this server.[/]"); + return false; + } + } + + var choices = new List(); + if (!string.IsNullOrWhiteSpace(authConfig.DeviceAuthorizationEndpoint)) + choices.Add("Device Flow"); + choices.AddRange(["Password Login", "Client Credentials", "Skip Login"]); + + var loginMethod = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("[green]Select login method:[/]") + .AddChoices(choices)); + + return loginMethod switch + { + "Device Flow" => await DeviceFlowLoginAsync(authConfig, cancellationToken), + "Password Login" => await PasswordLoginAsync(authConfig, cancellationToken), + "Client Credentials" => await ClientCredentialsLoginAsync(authConfig, cancellationToken), + _ => false + }; + } + + /// Clears the current authentication state, in-memory and file caches. + public void Logout() + { + _client.Logout(); + CurrentUser = null; + ClearCachedCredentials(); + ClearTokenFile(); + AnsiConsole.MarkupLine("[yellow]Logged out. Token cache cleared.[/]"); + } + + private async Task TryRefreshTokenAsync(CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_cachedTokenEndpoint)) + return false; + + try + { + using var httpClient = new HttpClient(); + + // Try refresh token first + if (!string.IsNullOrWhiteSpace(_cachedRefreshToken)) + { + var refreshResponse = await httpClient.RequestRefreshTokenAsync(new RefreshTokenRequest + { + Address = _cachedTokenEndpoint, + ClientId = _cachedClientId ?? "mcp-director", + RefreshToken = _cachedRefreshToken, + }, cancellationToken); + + if (!refreshResponse.IsError) + { + ApplyTokenResponse(refreshResponse); + _logger.LogInformation("Token refreshed via refresh_token for {User}", CurrentUser); + AnsiConsole.MarkupLine("[green]Token refreshed.[/]"); + return true; + } + + _logger.LogDebug("Refresh token failed: {Error}", refreshResponse.Error); + } + + // Fall back to re-authentication with cached credentials + if (_isClientCredentials && !string.IsNullOrWhiteSpace(_cachedClientSecret)) + { + var tokenResponse = await httpClient.RequestClientCredentialsTokenAsync( + new ClientCredentialsTokenRequest + { + Address = _cachedTokenEndpoint, + ClientId = _cachedClientId ?? "mcp-agent", + ClientSecret = _cachedClientSecret, + Scope = _cachedScopes ?? "mcp-api", + }, cancellationToken); + + if (!tokenResponse.IsError) + { + ApplyTokenResponse(tokenResponse); + _logger.LogInformation("Token refreshed via client_credentials for {ClientId}", _cachedClientId); + AnsiConsole.MarkupLine("[green]Token refreshed.[/]"); + return true; + } + } + else if (!string.IsNullOrWhiteSpace(_cachedUsername) && !string.IsNullOrWhiteSpace(_cachedPassword)) + { + var tokenResponse = await httpClient.RequestPasswordTokenAsync(new PasswordTokenRequest + { + Address = _cachedTokenEndpoint, + ClientId = _cachedClientId ?? "mcp-director", + Scope = _cachedScopes ?? "openid profile email", + UserName = _cachedUsername, + Password = _cachedPassword, + }, cancellationToken); + + if (!tokenResponse.IsError) + { + ApplyTokenResponse(tokenResponse); + _logger.LogInformation("Token refreshed via password grant for {User}", _cachedUsername); + AnsiConsole.MarkupLine("[green]Token refreshed.[/]"); + return true; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Token refresh failed"); + } + + return false; + } + + private void ApplyTokenResponse(TokenResponse tokenResponse) + { + _client.BearerToken = tokenResponse.AccessToken!; + _tokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn); + + if (!string.IsNullOrWhiteSpace(tokenResponse.RefreshToken)) + _cachedRefreshToken = tokenResponse.RefreshToken; + + SaveTokenToFile(); + } + + private void ClearCachedCredentials() + { + _cachedTokenEndpoint = null; + _cachedClientId = null; + _cachedScopes = null; + _cachedUsername = null; + _cachedPassword = null; + _cachedClientSecret = null; + _cachedRefreshToken = null; + _tokenExpiresAt = DateTimeOffset.MinValue; + _isClientCredentials = false; + } + + private async Task PasswordLoginAsync( + Client.Models.AuthConfigResponse authConfig, + CancellationToken cancellationToken) + { + var username = AnsiConsole.Prompt( + new TextPrompt("[green]Username:[/]") + .PromptStyle("yellow")); + + var password = AnsiConsole.Prompt( + new TextPrompt("[green]Password:[/]") + .PromptStyle("red") + .Secret()); + + return await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("[blue]Authenticating...[/]", async ctx => + { + try + { + using var httpClient = new HttpClient(); + var tokenEndpoint = authConfig.TokenEndpoint!; + var clientId = authConfig.ClientId ?? "mcp-director"; + var scopes = authConfig.Scopes ?? "openid profile email"; + + var tokenResponse = await httpClient.RequestPasswordTokenAsync(new PasswordTokenRequest + { + Address = tokenEndpoint, + ClientId = clientId, + Scope = scopes, + UserName = username, + Password = password, + }, cancellationToken); + + if (tokenResponse.IsError) + { + AnsiConsole.MarkupLine($"[red]Login failed: {Markup.Escape(tokenResponse.Error ?? "unknown error")}[/]"); + if (!string.IsNullOrWhiteSpace(tokenResponse.ErrorDescription)) + AnsiConsole.MarkupLine($"[dim]{Markup.Escape(tokenResponse.ErrorDescription)}[/]"); + _logger.LogWarning("Password login failed: {Error} {Description}", + tokenResponse.Error, tokenResponse.ErrorDescription); + return false; + } + + _cachedTokenEndpoint = tokenEndpoint; + _cachedClientId = clientId; + _cachedScopes = scopes; + _cachedUsername = username; + _cachedPassword = password; + _isClientCredentials = false; + + ApplyTokenResponse(tokenResponse); + CurrentUser = username; + + AnsiConsole.MarkupLine($"[green]Logged in as [bold]{Markup.Escape(username)}[/][/]"); + AnsiConsole.MarkupLine($"[dim]Token expires at {_tokenExpiresAt.LocalDateTime:HH:mm:ss} ({tokenResponse.ExpiresIn}s)[/]"); + _logger.LogInformation("Password login successful for user {User}, expires at {ExpiresAt}", + username, _tokenExpiresAt); + return true; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Login error: {Markup.Escape(ex.Message)}[/]"); + _logger.LogError(ex, "Password login error"); + return false; + } + }); + } + + private async Task ClientCredentialsLoginAsync( + Client.Models.AuthConfigResponse authConfig, + CancellationToken cancellationToken) + { + var clientId = AnsiConsole.Prompt( + new TextPrompt("[green]Client ID:[/]") + .DefaultValue("mcp-agent") + .PromptStyle("yellow")); + + var clientSecret = AnsiConsole.Prompt( + new TextPrompt("[green]Client Secret:[/]") + .PromptStyle("red") + .Secret()); + + return await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("[blue]Authenticating...[/]", async ctx => + { + try + { + using var httpClient = new HttpClient(); + var tokenEndpoint = authConfig.TokenEndpoint!; + + var scopes = authConfig.Scopes?.Split(' ') + .Where(s => s != "openid" && s != "profile" && s != "email") + .FirstOrDefault() ?? "mcp-api"; + + var tokenResponse = await httpClient.RequestClientCredentialsTokenAsync( + new ClientCredentialsTokenRequest + { + Address = tokenEndpoint, + ClientId = clientId, + ClientSecret = clientSecret, + Scope = scopes, + }, cancellationToken); + + if (tokenResponse.IsError) + { + AnsiConsole.MarkupLine($"[red]Login failed: {Markup.Escape(tokenResponse.Error ?? "unknown error")}[/]"); + if (!string.IsNullOrWhiteSpace(tokenResponse.ErrorDescription)) + AnsiConsole.MarkupLine($"[dim]{Markup.Escape(tokenResponse.ErrorDescription)}[/]"); + _logger.LogWarning("Client credentials login failed: {Error} {Description}", + tokenResponse.Error, tokenResponse.ErrorDescription); + return false; + } + + _cachedTokenEndpoint = tokenEndpoint; + _cachedClientId = clientId; + _cachedScopes = scopes; + _cachedClientSecret = clientSecret; + _isClientCredentials = true; + + ApplyTokenResponse(tokenResponse); + CurrentUser = $"{clientId} (service)"; + + AnsiConsole.MarkupLine($"[green]Authenticated as [bold]{Markup.Escape(clientId)}[/] (client credentials)[/]"); + AnsiConsole.MarkupLine($"[dim]Token expires at {_tokenExpiresAt.LocalDateTime:HH:mm:ss} ({tokenResponse.ExpiresIn}s)[/]"); + _logger.LogInformation("Client credentials login successful for {ClientId}, expires at {ExpiresAt}", + clientId, _tokenExpiresAt); + return true; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Login error: {Markup.Escape(ex.Message)}[/]"); + _logger.LogError(ex, "Client credentials login error"); + return false; + } + }); + } + + private async Task DeviceFlowLoginAsync( + Client.Models.AuthConfigResponse authConfig, + CancellationToken cancellationToken) + { + var deviceEndpoint = authConfig.DeviceAuthorizationEndpoint!; + var tokenEndpoint = authConfig.TokenEndpoint!; + var clientId = authConfig.ClientId ?? "mcp-director"; + var scopes = authConfig.Scopes ?? "openid profile email"; + + try + { + using var httpClient = new HttpClient(); + var content = new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = clientId, + ["scope"] = scopes, + }); + + var deviceResponse = await httpClient.PostAsync(deviceEndpoint, content, cancellationToken); + if (!deviceResponse.IsSuccessStatusCode) + { + var body = await deviceResponse.Content.ReadAsStringAsync(cancellationToken); + AnsiConsole.MarkupLine($"[red]Device authorization failed: {Markup.Escape(body)}[/]"); + return false; + } + + var deviceJson = await deviceResponse.Content.ReadAsStringAsync(cancellationToken); + var device = JsonSerializer.Deserialize(deviceJson, s_jsonOpts); + if (device is null) + { + AnsiConsole.MarkupLine("[red]Failed to parse device authorization response.[/]"); + return false; + } + + var targetUrl = device.VerificationUriComplete ?? device.VerificationUri; + + var panel = new Panel( + new Rows( + new Markup($"[bold yellow]User Code:[/] [bold white on blue] {Markup.Escape(device.UserCode)} [/]"), + new Markup(""), + new Markup($"[blue]Go to:[/] [link]{Markup.Escape(targetUrl)}[/]"), + new Markup(""), + new Markup("[dim]Enter the code above in your browser to complete login.[/]"), + new Markup("[dim]Waiting for authentication...[/]"))) + { + Header = new PanelHeader("[bold]Device Authorization[/]"), + Border = BoxBorder.Rounded, + Padding = new Padding(2, 1), + }; + + AnsiConsole.Write(panel); + AnsiConsole.WriteLine(); + + try + { + Process.Start(new ProcessStartInfo(targetUrl) { UseShellExecute = true }); + AnsiConsole.MarkupLine("[dim]Browser opened automatically.[/]"); + } + catch + { + AnsiConsole.MarkupLine("[dim]Could not open browser automatically. Please navigate manually.[/]"); + } + + var interval = device.Interval > 0 ? device.Interval : 5; + var deadline = DateTime.UtcNow.AddSeconds(device.ExpiresIn > 0 ? device.ExpiresIn : 300); + + while (DateTime.UtcNow < deadline && !cancellationToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(interval), cancellationToken); + + var pollContent = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code", + ["client_id"] = clientId, + ["device_code"] = device.DeviceCode, + }); + + var pollResponse = await httpClient.PostAsync(tokenEndpoint, pollContent, cancellationToken); + var pollJson = await pollResponse.Content.ReadAsStringAsync(cancellationToken); + var tokenResult = JsonSerializer.Deserialize(pollJson, s_jsonOpts); + + if (tokenResult is null) + continue; + + if (!string.IsNullOrEmpty(tokenResult.AccessToken) && string.IsNullOrEmpty(tokenResult.Error)) + { + _client.BearerToken = tokenResult.AccessToken; + _tokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(tokenResult.ExpiresIn); + _cachedRefreshToken = tokenResult.RefreshToken; + _cachedTokenEndpoint = tokenEndpoint; + _cachedClientId = clientId; + _cachedScopes = scopes; + _isClientCredentials = false; + + var username = ExtractUsernameFromJwt(tokenResult.AccessToken); + CurrentUser = username ?? "authenticated"; + + SaveTokenToFile(); + + AnsiConsole.MarkupLine($"[green]Logged in as [bold]{Markup.Escape(CurrentUser)}[/] (device flow)[/]"); + AnsiConsole.MarkupLine($"[dim]Token expires at {_tokenExpiresAt.LocalDateTime:HH:mm:ss} ({tokenResult.ExpiresIn}s)[/]"); + _logger.LogInformation("Device flow login successful for {User}, expires at {ExpiresAt}", + CurrentUser, _tokenExpiresAt); + return true; + } + + if (tokenResult.Error == "authorization_pending") + continue; + + if (tokenResult.Error == "slow_down") + { + interval += 5; + continue; + } + + AnsiConsole.MarkupLine($"[red]Device flow error: {Markup.Escape(tokenResult.Error ?? "unknown")}[/]"); + if (!string.IsNullOrWhiteSpace(tokenResult.ErrorDescription)) + AnsiConsole.MarkupLine($"[dim]{Markup.Escape(tokenResult.ErrorDescription)}[/]"); + return false; + } + + AnsiConsole.MarkupLine("[red]Device authorization flow timed out.[/]"); + return false; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Device flow error: {Markup.Escape(ex.Message)}[/]"); + _logger.LogError(ex, "Device flow login error"); + return false; + } + } + + private static string? ExtractUsernameFromJwt(string accessToken) + { + try + { + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(accessToken); + return jwt.Claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value + ?? jwt.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; + } + catch + { + return null; + } + } + + private static readonly JsonSerializerOptions s_jsonOpts = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + + private static readonly JsonSerializerOptions s_cacheJsonOpts = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + // ── DTOs ──────────────────────────────────────────────────────────── + + private sealed class CachedToken + { + public string AccessToken { get; set; } = ""; + public string RefreshToken { get; set; } = ""; + public DateTime ExpiresAtUtc { get; set; } + public string Authority { get; set; } = ""; + public string TokenEndpoint { get; set; } = ""; + public string ClientId { get; set; } = "mcp-director"; + } + + private sealed class DeviceAuthResponse + { + [JsonPropertyName("device_code")] + public string DeviceCode { get; set; } = ""; + [JsonPropertyName("user_code")] + public string UserCode { get; set; } = ""; + [JsonPropertyName("verification_uri")] + public string VerificationUri { get; set; } = ""; + [JsonPropertyName("verification_uri_complete")] + public string? VerificationUriComplete { get; set; } + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + [JsonPropertyName("interval")] + public int Interval { get; set; } + } + + private sealed class DeviceTokenResponse + { + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + [JsonPropertyName("error")] + public string? Error { get; set; } + [JsonPropertyName("error_description")] + public string? ErrorDescription { get; set; } + } +} diff --git a/src/McpServer.Repl.Host/McpServer.Repl.Host.csproj b/src/McpServer.Repl.Host/McpServer.Repl.Host.csproj index 9b2d84b..d414892 100644 --- a/src/McpServer.Repl.Host/McpServer.Repl.Host.csproj +++ b/src/McpServer.Repl.Host/McpServer.Repl.Host.csproj @@ -23,6 +23,8 @@ + + diff --git a/src/McpServer.Repl.Host/Program.cs b/src/McpServer.Repl.Host/Program.cs index 1c1a152..ac9d34d 100644 --- a/src/McpServer.Repl.Host/Program.cs +++ b/src/McpServer.Repl.Host/Program.cs @@ -12,10 +12,7 @@ using McpServer.Repl.Host; using McpServer.Client; -var versionOption = new Option("--version", "Display version information"); - var rootCommand = new RootCommand("MCP Server REPL Host"); -rootCommand.AddOption(versionOption); var agentStdioCommand = new Command("--agent-stdio", "Run in agent STDIO mode for MCP protocol communication"); agentStdioCommand.SetHandler(async (context) => @@ -36,17 +33,8 @@ rootCommand.AddCommand(agentStdioCommand); rootCommand.AddCommand(interactiveCommand); -rootCommand.SetHandler((bool showVersion) => +rootCommand.SetHandler(() => { - if (showVersion) - { - var version = Assembly.GetExecutingAssembly() - .GetCustomAttribute() - ?.InformationalVersion ?? "6.0.0"; - Console.WriteLine($"mcpserver-repl version {version}"); - return; - } - Console.WriteLine("MCP Server REPL Host"); Console.WriteLine(); Console.WriteLine("Usage:"); @@ -60,7 +48,7 @@ Console.WriteLine(" --interactive Run in interactive REPL mode"); Console.WriteLine(" --agent-stdio Run in agent STDIO mode for MCP protocol communication"); Console.WriteLine(); -}, versionOption); +}); return await rootCommand.InvokeAsync(args); @@ -73,7 +61,7 @@ static IHost CreateHost() services.AddSingleton(sp => { - var serverUrl = Environment.GetEnvironmentVariable("MCP_SERVER_URL") ?? "http://localhost:5000"; + var serverUrl = Environment.GetEnvironmentVariable("MCP_SERVER_URL") ?? "http://localhost:7147"; var options = new McpServerClientOptions { BaseUrl = new Uri(serverUrl) @@ -83,6 +71,7 @@ static IHost CreateHost() }); services.AddTransient(); + services.AddTransient(); services.AddTransient(); }) .Build(); diff --git a/src/McpServer.Repl.Host/README.md b/src/McpServer.Repl.Host/README.md index 7edacc2..d4fbd56 100644 --- a/src/McpServer.Repl.Host/README.md +++ b/src/McpServer.Repl.Host/README.md @@ -8,10 +8,10 @@ A command-line REPL (Read-Eval-Print Loop) host for interacting with the Model C ```powershell # Pack the tool (from solution root) -.\scripts\Pack-ReplTool.ps1 +./build.ps1 PackReplTool # Install globally -.\scripts\Install-ReplTool.ps1 +./build.ps1 InstallReplTool # Or install manually dotnet tool install --global SharpNinja.McpServer.Repl --add-source ./local-packages @@ -20,7 +20,7 @@ dotnet tool install --global SharpNinja.McpServer.Repl --add-source ./local-pack ### Update Existing Installation ```powershell -.\scripts\Install-ReplTool.ps1 -Update +./build.ps1 InstallReplTool --update-tool # Or manually dotnet tool update --global SharpNinja.McpServer.Repl --add-source ./local-packages @@ -29,7 +29,7 @@ dotnet tool update --global SharpNinja.McpServer.Repl --add-source ./local-packa ### Uninstall ```powershell -.\scripts\Install-ReplTool.ps1 -Uninstall +./build.ps1 InstallReplTool --uninstall-tool # Or manually dotnet tool uninstall --global SharpNinja.McpServer.Repl @@ -128,13 +128,15 @@ mcpserver-repl --interactive ### Build ```powershell -dotnet build src/McpServer.Repl.Host/McpServer.Repl.Host.csproj --configuration Release +./build.ps1 Compile --configuration Release +# or: dotnet build src/McpServer.Repl.Host/McpServer.Repl.Host.csproj --configuration Release ``` ### Pack ```powershell -dotnet pack src/McpServer.Repl.Host/McpServer.Repl.Host.csproj --configuration Release --output ./local-packages +./build.ps1 PackReplTool +# or: dotnet pack src/McpServer.Repl.Host/McpServer.Repl.Host.csproj --configuration Release --output ./local-packages ``` ### Run Locally (Without Installing) diff --git a/src/McpServer.Services/Services/HostedMcpAgentExecutionStrategy.cs b/src/McpServer.Services/Services/HostedMcpAgentExecutionStrategy.cs index 50ad2bb..11750b7 100644 --- a/src/McpServer.Services/Services/HostedMcpAgentExecutionStrategy.cs +++ b/src/McpServer.Services/Services/HostedMcpAgentExecutionStrategy.cs @@ -3,14 +3,16 @@ using System.Text.Json; using McpServer.McpAgent; using McpServer.McpAgent.Hosting; -using McpServer.McpAgent.SessionLog; -using McpServer.McpAgent.Todo; using McpServer.Client; using McpServer.Common.Copilot; +using McpServer.Repl.Core; using McpServer.Support.Mcp.Options; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Options; +using AgentSessionLogWorkflow = McpServer.McpAgent.SessionLog.SessionLogWorkflow; +using AgentTodoWorkflow = McpServer.McpAgent.Todo.TodoWorkflow; +using ReplSessionLogImpl = McpServer.Repl.Core.SessionLogWorkflow; namespace McpServer.Support.Mcp.Services; @@ -56,8 +58,12 @@ public ValueTask CreateSessionAsync( }); var optionsMonitor = Microsoft.Extensions.Options.Options.Create(hostedOptions); var identifiers = new McpSessionIdentifierFactory(optionsMonitor, TimeProvider.System); - var sessionLog = new SessionLogWorkflow(client, identifiers, TimeProvider.System); - var todo = new TodoWorkflow(client); + var sessionLog = new AgentSessionLogWorkflow(client, identifiers, TimeProvider.System); + var todo = new AgentTodoWorkflow(client); + var requirements = new RequirementsWorkflow(client.Requirements); + var clientPassthrough = new GenericClientPassthrough(client); + var replSessionLogAdapter = new SessionLogClientAdapter(client.SessionLog); + var replSessionLog = new ReplSessionLogImpl(replSessionLogAdapter, TimeProvider.System); var hostedAgent = new McpHostedAgent( client, identifiers, @@ -70,6 +76,9 @@ public ValueTask CreateSessionAsync( optionsMonitor, sessionLog, todo, + requirements, + clientPassthrough, + replSessionLog, serviceProvider); return ValueTask.FromResult( diff --git a/src/McpServer.Support.Mcp/Controllers/AuthConfigController.cs b/src/McpServer.Support.Mcp/Controllers/AuthConfigController.cs index 2c2b19c..b4d8f4a 100644 --- a/src/McpServer.Support.Mcp/Controllers/AuthConfigController.cs +++ b/src/McpServer.Support.Mcp/Controllers/AuthConfigController.cs @@ -3,9 +3,11 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; +using McpServer.Support.Mcp.Identity; using McpServer.Support.Mcp.Options; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; +using IdentityServerOptions = McpServer.Support.Mcp.Identity.IdentityServerOptions; namespace McpServer.Support.Mcp.Controllers; @@ -25,12 +27,31 @@ public sealed class AuthConfigController : ControllerBase /// No secrets are exposed — only the authority URL, public client ID, and endpoint URLs. /// /// Bound from Mcp:Auth configuration section. + /// Bound from Mcp:IdentityServer configuration section. /// Public auth configuration or a disabled indicator. [HttpGet("config")] [ProducesResponseType(typeof(AuthConfigResponse), 200)] - public IActionResult GetConfig([FromServices] IOptions options) + public IActionResult GetConfig( + [FromServices] IOptions options, + [FromServices] IOptions identityServerOptions) { var auth = options.Value; + var ids = identityServerOptions.Value; + var proxyBaseUrl = $"{Request.Scheme}://{Request.Host}"; + + // Embedded IdentityServer takes precedence when enabled and no external authority is set + if (ids.Enabled && !auth.Enabled) + { + return Ok(new AuthConfigResponse + { + Enabled = true, + Authority = proxyBaseUrl, + ClientId = "mcp-director", + Scopes = $"openid profile email roles {ids.ApiScopeName}", + DeviceAuthorizationEndpoint = $"{proxyBaseUrl}/connect/deviceauthorization", + TokenEndpoint = $"{proxyBaseUrl}/connect/token" + }); + } if (!auth.Enabled) { @@ -46,7 +67,6 @@ public IActionResult GetConfig([FromServices] IOptions options) } var authority = auth.Authority.TrimEnd('/'); - var proxyBaseUrl = $"{Request.Scheme}://{Request.Host}"; return Ok(new AuthConfigResponse { @@ -60,55 +80,104 @@ public IActionResult GetConfig([FromServices] IOptions options) } /// - /// Proxies the OAuth 2.0 Device Authorization request to Keycloak so clients - /// can stay on the MCP host/port (e.g. Android can call :7147 instead of :7080). + /// Proxies the OAuth 2.0 Device Authorization request to the configured authority. + /// When embedded IdentityServer is active, forwards to the local /connect/deviceauthorization endpoint. + /// Otherwise proxies to the external Keycloak instance. /// [HttpPost("device")] [Consumes("application/x-www-form-urlencoded")] public Task ProxyDeviceAuthorization( [FromServices] IOptions options, + [FromServices] IOptions identityServerOptions, [FromServices] IHttpClientFactory httpClientFactory, [FromServices] ILogger logger, CancellationToken cancellationToken) - => ProxyOidcFormPostAsync( + { + var ids = identityServerOptions.Value; + if (ids.Enabled && !options.Value.Enabled) + { + var localEndpoint = $"{Request.Scheme}://{Request.Host}/connect/deviceauthorization"; + return ProxyOidcFormPostAsync( + options.Value, + localEndpoint, + httpClientFactory, + logger, + cancellationToken, + rewriteDeviceVerificationUris: false, + bypassEnabledCheck: true); + } + + return ProxyOidcFormPostAsync( options.Value, GetDeviceAuthorizationEndpoint(options.Value), httpClientFactory, logger, cancellationToken, rewriteDeviceVerificationUris: true); + } /// - /// Proxies the OAuth 2.0 Token request to Keycloak so clients can stay on the - /// MCP host/port (e.g. Android can call :7147 instead of :7080). + /// Proxies the OAuth 2.0 Token request to the configured authority. + /// When embedded IdentityServer is active, forwards to the local /connect/token endpoint. + /// Otherwise proxies to the external Keycloak instance. /// [HttpPost("token")] [Consumes("application/x-www-form-urlencoded")] public Task ProxyToken( [FromServices] IOptions options, + [FromServices] IOptions identityServerOptions, [FromServices] IHttpClientFactory httpClientFactory, [FromServices] ILogger logger, CancellationToken cancellationToken) - => ProxyOidcFormPostAsync( + { + var ids = identityServerOptions.Value; + if (ids.Enabled && !options.Value.Enabled) + { + var localEndpoint = $"{Request.Scheme}://{Request.Host}/connect/token"; + return ProxyOidcFormPostAsync( + options.Value, + localEndpoint, + httpClientFactory, + logger, + cancellationToken, + enforceMinimumTokenLifetime: true, + bypassEnabledCheck: true); + } + + return ProxyOidcFormPostAsync( options.Value, GetTokenEndpoint(options.Value), httpClientFactory, logger, cancellationToken, enforceMinimumTokenLifetime: true); + } /// - /// Browser-facing Keycloak UI proxy for device-flow verification pages and supporting assets. - /// Keeps browser traffic on the MCP host/port instead of requiring direct Keycloak access. + /// Browser-facing UI proxy for device-flow verification pages and supporting assets. + /// When embedded IdentityServer is active, redirects to the local IdentityServer UI. + /// Otherwise proxies to the external Keycloak instance. /// [HttpGet("ui/{**path}")] [HttpPost("ui/{**path}")] public Task ProxyBrowserUi( string? path, [FromServices] IOptions options, + [FromServices] IOptions identityServerOptions, [FromServices] ILogger logger, CancellationToken cancellationToken) - => ProxyOidcBrowserUiAsync(path, options.Value, logger, cancellationToken); + { + var ids = identityServerOptions.Value; + if (ids.Enabled && !options.Value.Enabled) + { + // IdentityServer serves its own UI; redirect to it + var localPath = NormalizeUiProxyPath(path); + var redirectUrl = $"/{localPath}{Request.QueryString}"; + return Task.FromResult(Redirect(redirectUrl)); + } + + return ProxyOidcBrowserUiAsync(path, options.Value, logger, cancellationToken); + } private async Task ProxyOidcFormPostAsync( OidcAuthOptions authOptions, @@ -117,15 +186,23 @@ private async Task ProxyOidcFormPostAsync( ILogger logger, CancellationToken cancellationToken, bool rewriteDeviceVerificationUris = false, - bool enforceMinimumTokenLifetime = false) + bool enforceMinimumTokenLifetime = false, + bool bypassEnabledCheck = false) { - if (string.IsNullOrWhiteSpace(endpoint)) + if (string.IsNullOrWhiteSpace(endpoint) && !bypassEnabledCheck) { return Problem( title: "OIDC authentication is not enabled.", statusCode: StatusCodes.Status503ServiceUnavailable); } + if (string.IsNullOrWhiteSpace(endpoint)) + { + return Problem( + title: "No endpoint configured.", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + string body; using (var reader = new StreamReader(Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true)) { diff --git a/src/McpServer.Support.Mcp/Identity/DeviceFlowController.cs b/src/McpServer.Support.Mcp/Identity/DeviceFlowController.cs new file mode 100644 index 0000000..22b9cda --- /dev/null +++ b/src/McpServer.Support.Mcp/Identity/DeviceFlowController.cs @@ -0,0 +1,217 @@ +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using IdentityServerOptions = McpServer.Support.Mcp.Identity.IdentityServerOptions; + +namespace McpServer.Support.Mcp.Identity; + +/// +/// Minimal device-flow verification UI for the embedded IdentityServer. +/// Handles the browser-side of the OAuth 2.0 Device Authorization Grant: +/// user enters the code, we validate and grant consent automatically. +/// +[Route("device")] +public sealed class DeviceFlowController : Controller +{ + private readonly IDeviceFlowInteractionService _interaction; + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + + /// Initializes a new instance. + public DeviceFlowController( + IDeviceFlowInteractionService interaction, + SignInManager signInManager, + UserManager userManager) + { + _interaction = interaction; + _signInManager = signInManager; + _userManager = userManager; + } + + /// + /// GET /device — Shows a minimal HTML form to enter the user code, + /// or auto-submits if the code is provided via query string. + /// + [HttpGet] + public async Task Index( + [FromQuery] string? userCode, + [FromServices] IOptions idsOptions) + { + if (!idsOptions.Value.Enabled) + return NotFound(); + + if (!string.IsNullOrWhiteSpace(userCode)) + { + // Auto-validate if user code is in query string + return await ProcessUserCode(userCode); + } + + // Show code entry form + return Content(BuildCodeEntryHtml(), "text/html"); + } + + /// + /// POST /device — Processes the submitted user code. + /// + [HttpPost] + public async Task Submit( + [FromForm] string userCode, + [FromServices] IOptions idsOptions) + { + if (!idsOptions.Value.Enabled) + return NotFound(); + + if (string.IsNullOrWhiteSpace(userCode)) + return Content(BuildCodeEntryHtml("Please enter the code displayed in your terminal."), "text/html"); + + return await ProcessUserCode(userCode.Trim()); + } + + private async Task ProcessUserCode(string userCode) + { + var request = await _interaction.GetAuthorizationContextAsync(userCode); + if (request is null) + { + return Content(BuildCodeEntryHtml("Invalid or expired code. Please try again."), "text/html"); + } + + // If the user is not signed in, show a login form + if (!User.Identity?.IsAuthenticated ?? true) + { + return Content(BuildLoginHtml(userCode), "text/html"); + } + + // User is authenticated — grant consent for all requested scopes + var consent = new Duende.IdentityServer.Models.ConsentResponse + { + ScopesValuesConsented = request.ValidatedResources.RawScopeValues, + }; + await _interaction.HandleRequestAsync(userCode, consent); + + return Content(BuildSuccessHtml(), "text/html"); + } + + /// + /// POST /device/login — Handles username/password login during device flow. + /// + [HttpPost("login")] + public async Task Login( + [FromForm] string userCode, + [FromForm] string username, + [FromForm] string password, + [FromServices] IOptions idsOptions) + { + if (!idsOptions.Value.Enabled) + return NotFound(); + + var request = await _interaction.GetAuthorizationContextAsync(userCode); + if (request is null) + return Content(BuildCodeEntryHtml("Invalid or expired code."), "text/html"); + + var result = await _signInManager.PasswordSignInAsync(username, password, isPersistent: false, lockoutOnFailure: false); + if (!result.Succeeded) + { + return Content(BuildLoginHtml(userCode, "Invalid username or password."), "text/html"); + } + + // Grant consent for all requested scopes + var consent = new Duende.IdentityServer.Models.ConsentResponse + { + ScopesValuesConsented = request.ValidatedResources.RawScopeValues, + }; + await _interaction.HandleRequestAsync(userCode, consent); + + return Content(BuildSuccessHtml(), "text/html"); + } + + // ── HTML templates ────────────────────────────────────────────────── + + private static string BuildCodeEntryHtml(string? error = null) + { + var errorBlock = error is not null + ? $"""

{System.Net.WebUtility.HtmlEncode(error)}

""" + : ""; + + return $$""" + + Device Authorization — MCP Server + + +
+

MCP Server — Device Authorization

+

Enter the code displayed in your terminal

+ {{errorBlock}} +
+
+ +
+
+ + """; + } + + private static string BuildLoginHtml(string userCode, string? error = null) + { + var errorBlock = error is not null + ? $"""

{System.Net.WebUtility.HtmlEncode(error)}

""" + : ""; + + return $$""" + + Sign In — MCP Server + + +
+

Sign In to MCP Server

+

Authenticate to authorize the device

+ {{errorBlock}} +
+ +
+
+ +
+
+ + """; + } + + private static string BuildSuccessHtml() + { + return """ + + Authorized — MCP Server + + +
+
+

Device Authorized

+

You can close this window and return to your terminal.

+
+ + """; + } +} diff --git a/src/McpServer.Support.Mcp/Identity/IdentityServerConfig.cs b/src/McpServer.Support.Mcp/Identity/IdentityServerConfig.cs new file mode 100644 index 0000000..c35dfd6 --- /dev/null +++ b/src/McpServer.Support.Mcp/Identity/IdentityServerConfig.cs @@ -0,0 +1,82 @@ +using Duende.IdentityServer.Models; +using DuendeClient = Duende.IdentityServer.Models.Client; + +namespace McpServer.Support.Mcp.Identity; + +/// +/// Static IdentityServer resource and client definitions for the MCP Server. +/// +internal static class IdentityServerConfig +{ + public static IEnumerable GetIdentityResources() => + [ + new IdentityResources.OpenId(), + new IdentityResources.Profile(), + new IdentityResources.Email(), + new IdentityResource("roles", "User roles", ["role", "realm_roles"]), + ]; + + public static IEnumerable GetApiScopes(IdentityServerOptions options) => + [ + new ApiScope(options.ApiScopeName, "MCP Server API") + { + UserClaims = ["role", "realm_roles", "preferred_username"], + }, + ]; + + public static IEnumerable GetApiResources(IdentityServerOptions options) => + [ + new ApiResource(options.ApiResourceName, "MCP Server API") + { + Scopes = { options.ApiScopeName }, + UserClaims = ["role", "realm_roles", "preferred_username"], + }, + ]; + + public static IEnumerable GetClients(IdentityServerOptions options) => + [ + // Machine-to-machine client for agents and services + new DuendeClient + { + ClientId = "mcp-agent", + ClientName = "MCP Agent Client", + AllowedGrantTypes = GrantTypes.ClientCredentials, + ClientSecrets = { new Secret("mcp-agent-secret".Sha256()) }, + AllowedScopes = { options.ApiScopeName }, + }, + + // Interactive client for CLI tools (Device Authorization + Password flows) + new DuendeClient + { + ClientId = "mcp-director", + ClientName = "MCP Director CLI", + AllowedGrantTypes = + { + "urn:ietf:params:oauth:grant-type:device_code", + GrantType.ResourceOwnerPassword, + }, + RequireClientSecret = false, + AllowedScopes = { "openid", "profile", "email", "roles", options.ApiScopeName }, + AllowOfflineAccess = true, + AccessTokenLifetime = 3600, + RefreshTokenUsage = TokenUsage.ReUse, + RefreshTokenExpiration = TokenExpiration.Sliding, + SlidingRefreshTokenLifetime = 86400, + }, + + // Web/SPA client for pairing UI and browser-based access + new DuendeClient + { + ClientId = "mcp-web", + ClientName = "MCP Web Client", + AllowedGrantTypes = GrantTypes.Code, + RequirePkce = true, + RequireClientSecret = false, + RedirectUris = { "http://localhost:7147/auth/callback", "https://localhost:7147/auth/callback" }, + PostLogoutRedirectUris = { "http://localhost:7147/", "https://localhost:7147/" }, + AllowedCorsOrigins = { "http://localhost:7147", "https://localhost:7147" }, + AllowedScopes = { "openid", "profile", "email", "roles", options.ApiScopeName }, + AllowOfflineAccess = true, + }, + ]; +} diff --git a/src/McpServer.Support.Mcp/Identity/IdentityServerExtensions.cs b/src/McpServer.Support.Mcp/Identity/IdentityServerExtensions.cs new file mode 100644 index 0000000..19f44df --- /dev/null +++ b/src/McpServer.Support.Mcp/Identity/IdentityServerExtensions.cs @@ -0,0 +1,76 @@ +using Duende.IdentityServer; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace McpServer.Support.Mcp.Identity; + +/// +/// Extension methods to register the embedded IdentityServer in the MCP Server host. +/// +internal static class IdentityServerExtensions +{ + public static IServiceCollection AddMcpIdentityServer( + this IServiceCollection services, + IConfiguration configuration) + { + var options = configuration.GetSection(IdentityServerOptions.SectionName).Get() + ?? new IdentityServerOptions(); + + if (!options.Enabled) + return services; + + var identityConnectionString = options.ConnectionString; + if (string.IsNullOrWhiteSpace(identityConnectionString)) + { + // Default to SQL Server LocalDB + identityConnectionString = $"Server=(localdb)\\MSSQLLocalDB;Database={options.DatabaseName};Trusted_Connection=True;MultipleActiveResultSets=true"; + } + + // ASP.NET Core Identity backed by SQL Server + services.AddDbContext(opts => + opts.UseSqlServer(identityConnectionString)); + + services.AddIdentity(opts => + { + opts.Password.RequireDigit = false; + opts.Password.RequiredLength = 4; + opts.Password.RequireNonAlphanumeric = false; + opts.Password.RequireUppercase = false; + opts.Password.RequireLowercase = false; + opts.User.RequireUniqueEmail = false; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + // Duende IdentityServer + var isBuilder = services.AddIdentityServer(idsvr => + { + if (!string.IsNullOrWhiteSpace(options.IssuerUri)) + idsvr.IssuerUri = options.IssuerUri; + + idsvr.EmitStaticAudienceClaim = true; + idsvr.UserInteraction.DeviceVerificationUrl = "/device"; + idsvr.UserInteraction.DeviceVerificationUserCodeParameter = "userCode"; + }) + .AddAspNetIdentity() + .AddInMemoryIdentityResources(IdentityServerConfig.GetIdentityResources()) + .AddInMemoryApiScopes(IdentityServerConfig.GetApiScopes(options)) + .AddInMemoryApiResources(IdentityServerConfig.GetApiResources(options)) + .AddInMemoryClients(IdentityServerConfig.GetClients(options)); + + return services; + } + + public static WebApplication UseMcpIdentityServer(this WebApplication app) + { + var options = app.Configuration.GetSection(IdentityServerOptions.SectionName).Get() + ?? new IdentityServerOptions(); + + if (!options.Enabled) + return app; + + app.UseIdentityServer(); + + return app; + } +} diff --git a/src/McpServer.Support.Mcp/Identity/IdentityServerOptions.cs b/src/McpServer.Support.Mcp/Identity/IdentityServerOptions.cs new file mode 100644 index 0000000..59bb1c8 --- /dev/null +++ b/src/McpServer.Support.Mcp/Identity/IdentityServerOptions.cs @@ -0,0 +1,38 @@ +namespace McpServer.Support.Mcp.Identity; + +/// +/// Configuration options for the embedded IdentityServer instance. +/// Bound from Mcp:IdentityServer configuration section. +/// +public sealed class IdentityServerOptions +{ + /// Configuration section path. + public const string SectionName = "Mcp:IdentityServer"; + + /// Whether the embedded IdentityServer is enabled. Default: false. + public bool Enabled { get; set; } + + /// The issuer URI for tokens. Defaults to the server's base URL. + public string IssuerUri { get; set; } = ""; + + /// SQL Server connection string for identity data. When empty, defaults to LocalDB. + public string ConnectionString { get; set; } = ""; + + /// Database name for the identity store (used in default LocalDB connection string). + public string DatabaseName { get; set; } = "McpIdentity"; + + /// Whether to seed default clients and resources on startup. + public bool SeedDefaults { get; set; } = true; + + /// Default admin username seeded on first run. + public string DefaultAdminUser { get; set; } = "admin"; + + /// Default admin password seeded on first run. Change after initial setup. + public string DefaultAdminPassword { get; set; } = "McpAdmin1!"; + + /// API scope name for the MCP Server API. + public string ApiScopeName { get; set; } = "mcp-api"; + + /// API resource name. + public string ApiResourceName { get; set; } = "mcp-server-api"; +} diff --git a/src/McpServer.Support.Mcp/Identity/IdentityServerSeeder.cs b/src/McpServer.Support.Mcp/Identity/IdentityServerSeeder.cs new file mode 100644 index 0000000..3a1a333 --- /dev/null +++ b/src/McpServer.Support.Mcp/Identity/IdentityServerSeeder.cs @@ -0,0 +1,75 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace McpServer.Support.Mcp.Identity; + +/// +/// Seeds the IdentityServer databases with default configuration and an admin user on first run. +/// +internal static class IdentityServerSeeder +{ + public static async Task SeedAsync(IServiceProvider services, IdentityServerOptions options) + { + using var scope = services.CreateScope(); + var sp = scope.ServiceProvider; + + // Create Identity database schema (no migrations assembly — use EnsureCreated) + var identityDb = sp.GetRequiredService(); + await identityDb.Database.EnsureCreatedAsync(); + + // Seed default admin user + var userManager = sp.GetRequiredService>(); + var roleManager = sp.GetRequiredService>(); + + foreach (var roleName in new[] { "admin", "agent-manager" }) + { + if (!await roleManager.RoleExistsAsync(roleName)) + await roleManager.CreateAsync(new IdentityRole(roleName)); + } + + var adminUser = await userManager.FindByNameAsync(options.DefaultAdminUser); + if (adminUser is null) + { + adminUser = new McpUser + { + UserName = options.DefaultAdminUser, + Email = $"{options.DefaultAdminUser}@localhost", + EmailConfirmed = true, + DisplayName = "MCP Administrator", + }; + var result = await userManager.CreateAsync(adminUser, options.DefaultAdminPassword); + if (result.Succeeded) + { + await userManager.AddToRolesAsync(adminUser, ["admin", "agent-manager"]); + } + } + + // Seed additional users + await EnsureUserAsync(userManager, "plbyrd", "plbyrd", "P.L. Byrd", ["admin", "agent-manager"]); + } + + private static async Task EnsureUserAsync( + UserManager userManager, + string userName, + string password, + string displayName, + string[] roles) + { + var existing = await userManager.FindByNameAsync(userName); + if (existing is not null) + return; + + var user = new McpUser + { + UserName = userName, + Email = $"{userName}@localhost", + EmailConfirmed = true, + DisplayName = displayName, + }; + var result = await userManager.CreateAsync(user, password); + if (result.Succeeded) + { + await userManager.AddToRolesAsync(user, roles); + } + } +} diff --git a/src/McpServer.Support.Mcp/Identity/McpIdentityDbContext.cs b/src/McpServer.Support.Mcp/Identity/McpIdentityDbContext.cs new file mode 100644 index 0000000..b1e473f --- /dev/null +++ b/src/McpServer.Support.Mcp/Identity/McpIdentityDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace McpServer.Support.Mcp.Identity; + +/// +/// Identity DbContext for IdentityServer user management. +/// Uses a separate SQLite database from the main MCP data store. +/// +public sealed class McpIdentityDbContext : IdentityDbContext +{ + /// Initializes a new instance with the specified options. + public McpIdentityDbContext(DbContextOptions options) + : base(options) { } +} diff --git a/src/McpServer.Support.Mcp/Identity/McpUser.cs b/src/McpServer.Support.Mcp/Identity/McpUser.cs new file mode 100644 index 0000000..f571bd4 --- /dev/null +++ b/src/McpServer.Support.Mcp/Identity/McpUser.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Identity; + +namespace McpServer.Support.Mcp.Identity; + +/// +/// Application user for IdentityServer authentication. +/// +public sealed class McpUser : IdentityUser +{ + /// Display name shown in tokens and UI. + public string? DisplayName { get; set; } +} diff --git a/src/McpServer.Support.Mcp/McpServer.Support.Mcp.csproj b/src/McpServer.Support.Mcp/McpServer.Support.Mcp.csproj index 9083f6f..285caa2 100644 --- a/src/McpServer.Support.Mcp/McpServer.Support.Mcp.csproj +++ b/src/McpServer.Support.Mcp/McpServer.Support.Mcp.csproj @@ -17,11 +17,15 @@
- - - - - + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -45,15 +49,15 @@ - - - - - - - - - + + + + + + + + + <_Parameter1>McpServer.Support.Mcp.Tests @@ -65,9 +69,9 @@ <_Parameter1>McpServer.SpecFlow.Tests - - - PreserveNewest - - - + + + PreserveNewest + + + diff --git a/src/McpServer.Support.Mcp/Program.cs b/src/McpServer.Support.Mcp/Program.cs index 0a1c9d1..b72481c 100644 --- a/src/McpServer.Support.Mcp/Program.cs +++ b/src/McpServer.Support.Mcp/Program.cs @@ -17,6 +17,7 @@ using McpServer.Support.Mcp.McpStdio; using McpServer.Support.Mcp.Middleware; using McpServer.Support.Mcp.Notifications; +using McpServer.Support.Mcp.Identity; using McpServer.Support.Mcp.Options; using McpServer.Support.Mcp.Requirements; using McpServer.Support.Mcp.Controllers; @@ -376,27 +377,47 @@ static string ResolvePath(string repoRootPath, string path) => builder.Services.Configure(builder.Configuration.GetSection(DesktopLaunchOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(PairingOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(OidcAuthOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(IdentityServerOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(ToolRegistryOptions.SectionName)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Embedded IdentityServer (when enabled, acts as the local OIDC authority) +var identityServerOptions = builder.Configuration.GetSection(IdentityServerOptions.SectionName).Get() + ?? new IdentityServerOptions(); +builder.Services.AddMcpIdentityServer(builder.Configuration); + var oidcAuthBootstrap = builder.Configuration.GetSection(OidcAuthOptions.SectionName).Get() ?? new OidcAuthOptions(); -if (oidcAuthBootstrap.Enabled) +// When embedded IdentityServer is enabled and no external authority is configured, +// point JWT validation at the local IdentityServer instance. +var effectiveAuthority = oidcAuthBootstrap.Authority; +var effectiveAudience = oidcAuthBootstrap.Audience; +var authEnabled = oidcAuthBootstrap.Enabled || identityServerOptions.Enabled; + +if (identityServerOptions.Enabled && !oidcAuthBootstrap.Enabled) +{ + effectiveAuthority = !string.IsNullOrWhiteSpace(identityServerOptions.IssuerUri) + ? identityServerOptions.IssuerUri + : $"http://localhost:{listenPort}"; + effectiveAudience = identityServerOptions.ApiResourceName; +} + +if (authEnabled) { builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.MapInboundClaims = false; - options.Authority = oidcAuthBootstrap.Authority; - options.Audience = oidcAuthBootstrap.Audience; + options.Authority = effectiveAuthority; + options.Audience = effectiveAudience; options.RequireHttpsMetadata = oidcAuthBootstrap.RequireHttpsMetadata; options.TokenValidationParameters = new TokenValidationParameters { NameClaimType = "preferred_username", RoleClaimType = "realm_roles", - ValidateAudience = !string.IsNullOrWhiteSpace(oidcAuthBootstrap.Audience), + ValidateAudience = !string.IsNullOrWhiteSpace(effectiveAudience), }; }); } @@ -409,7 +430,7 @@ static string ResolvePath(string repoRootPath, string path) => { options.AddPolicy("AgentManager", policy => { - if (!oidcAuthBootstrap.Enabled) + if (!authEnabled) { policy.RequireAssertion(_ => true); return; @@ -565,6 +586,7 @@ static string ResolvePath(string repoRootPath, string path) => app.UseGlobalExceptionHandler(); app.UseMiddleware(); +app.UseMcpIdentityServer(); app.UseAuthentication(); app.UseMiddleware(); app.UseMiddleware(); @@ -716,6 +738,12 @@ await pairingRenderer.RenderLoginPageAsync("Invalid username or password.").Conf return Results.Content(await pairingRenderer.RenderKeyPageAsync(o.ApiKey, serverUrl).ConfigureAwait(false), "text/html"); }).ExcludeFromDescription(); +// Seed IdentityServer defaults (admin user, roles) on first run +if (identityServerOptions is { Enabled: true, SeedDefaults: true }) +{ + await IdentityServerSeeder.SeedAsync(app.Services, identityServerOptions); +} + try { await app.RunAsync().ConfigureAwait(false); diff --git a/src/McpServer.Support.Mcp/appsettings.yaml b/src/McpServer.Support.Mcp/appsettings.yaml index cc3ee18..1b9ee8a 100644 --- a/src/McpServer.Support.Mcp/appsettings.yaml +++ b/src/McpServer.Support.Mcp/appsettings.yaml @@ -138,6 +138,16 @@ Mcp: Username: '' Password: '' FallbackLogPath: logs/mcp-.log + IdentityServer: + Enabled: false + IssuerUri: '' + ConnectionString: '' + DatabaseName: McpIdentity + SeedDefaults: true + DefaultAdminUser: admin + DefaultAdminPassword: McpAdmin1! + ApiScopeName: mcp-api + ApiResourceName: mcp-server-api PairingUsers: [] Workspaces: - WorkspacePath: E:\github\McpServer diff --git a/tests/Build.Tests/Build.Tests.csproj b/tests/Build.Tests/Build.Tests.csproj new file mode 100644 index 0000000..d4a8fa6 --- /dev/null +++ b/tests/Build.Tests/Build.Tests.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + false + true + NukeBuild.Tests + + + + + + + + + + + + + + + diff --git a/tests/Build.Tests/BuildTargetTests.cs b/tests/Build.Tests/BuildTargetTests.cs new file mode 100644 index 0000000..0008604 --- /dev/null +++ b/tests/Build.Tests/BuildTargetTests.cs @@ -0,0 +1,64 @@ +namespace NukeBuild.Tests; + +/// +/// TEST-NUKE-001: Verifies that the Nuke _build project compiles and the Build class +/// is defined with the expected targets. Since NukeBuild requires specific runtime +/// initialization (assembly name = "_build"), we verify via reflection rather than +/// direct instantiation. +/// +public sealed class BuildTargetTests +{ + private static readonly Type BuildType = typeof(Build); + + [Fact] + public void Build_ExtendsNukeBuild() + { + Assert.True(BuildType.IsSubclassOf(typeof(Nuke.Common.NukeBuild))); + } + + [Fact] + public void Build_HasCompileTarget() + { + var prop = BuildType.GetProperty("Compile"); + Assert.NotNull(prop); + } + + [Fact] + public void Build_HasCleanTarget() + { + var prop = BuildType.GetProperty("Clean"); + Assert.NotNull(prop); + } + + [Fact] + public void Build_HasRestoreTarget() + { + var prop = BuildType.GetProperty("Restore"); + Assert.NotNull(prop); + } + + [Fact] + public void Build_HasConfigurationParameter() + { + var field = BuildType.GetField("Configuration"); + Assert.NotNull(field); + Assert.Equal(typeof(string), field!.FieldType); + } + + [Fact] + public void Build_HasSolutionField() + { + var field = BuildType.GetField("Solution", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Assert.NotNull(field); + } + + [Fact] + public void Build_HasDirectoryProperties() + { + Assert.NotNull(BuildType.GetProperty("SourceDirectory")); + Assert.NotNull(BuildType.GetProperty("TestsDirectory")); + Assert.NotNull(BuildType.GetProperty("ArtifactsDirectory")); + Assert.NotNull(BuildType.GetProperty("LocalPackagesDirectory")); + } +} diff --git a/tests/Build.Tests/ConfigValidatorTests.cs b/tests/Build.Tests/ConfigValidatorTests.cs new file mode 100644 index 0000000..aa0259f --- /dev/null +++ b/tests/Build.Tests/ConfigValidatorTests.cs @@ -0,0 +1,156 @@ +namespace NukeBuild.Tests; + +/// +/// TEST-NUKE-003: Verifies ConfigValidator correctly parses YAML appsettings +/// and validates MCP instance configuration including port conflicts, +/// missing fields, and provider settings. +/// +public sealed class ConfigValidatorTests +{ + private static readonly string[] ValidYaml = + [ + "Mcp:", + " Instances:", + " default:", + " RepoRoot: F:\\GitHub\\McpServer", + " Port: 7147", + " TodoStorage:", + " Provider: yaml", + " alt-local:", + " RepoRoot: F:\\GitHub\\McpServer", + " Port: 7148", + " TodoStorage:", + " Provider: sqlite", + " SqliteDataSource: todo.db", + ]; + + [Fact] + public void ParseInstances_ValidYaml_ReturnsTwoInstances() + { + var instances = ConfigValidator.ParseInstances(ValidYaml); + Assert.NotNull(instances); + Assert.Equal(2, instances.Count); + Assert.True(instances.ContainsKey("default")); + Assert.True(instances.ContainsKey("alt-local")); + } + + [Fact] + public void ParseInstances_ValidYaml_ParsesRepoRootAndPort() + { + var instances = ConfigValidator.ParseInstances(ValidYaml)!; + Assert.Equal(@"F:\GitHub\McpServer", instances["default"].RepoRoot); + Assert.Equal(7147, instances["default"].Port); + } + + [Fact] + public void ParseInstances_ValidYaml_ParsesTodoStorage() + { + var instances = ConfigValidator.ParseInstances(ValidYaml)!; + Assert.Equal("yaml", instances["default"].TodoProvider); + Assert.Equal("sqlite", instances["alt-local"].TodoProvider); + Assert.Equal("todo.db", instances["alt-local"].SqliteDataSource); + } + + [Fact] + public void ParseInstances_NoMcpSection_ReturnsNull() + { + var result = ConfigValidator.ParseInstances(["Logging:", " Level: Debug"]); + Assert.Null(result); + } + + [Fact] + public void ParseInstances_EmptyInstances_ReturnsEmptyDict() + { + var result = ConfigValidator.ParseInstances(["Mcp:", " Instances:", " Port: 7147"]); + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ParseInstances_QuotedValues_UnquotesCorrectly() + { + string[] yaml = ["Mcp:", " Instances:", " test:", " RepoRoot: 'C:\\test'", " Port: \"7150\""]; + var instances = ConfigValidator.ParseInstances(yaml)!; + Assert.Equal(@"C:\test", instances["test"].RepoRoot); + Assert.Equal(7150, instances["test"].Port); + } + + [Fact] + public void Validate_DuplicatePorts_ReturnsError() + { + var instances = new Dictionary + { + ["a"] = new() { RepoRoot = "C:\\test", Port = 7147 }, + ["b"] = new() { RepoRoot = "C:\\test", Port = 7147 }, + }; + + var errors = ConfigValidator.Validate(instances, _ => true); + Assert.Single(errors); + Assert.Contains("Duplicate port", errors[0]); + } + + [Fact] + public void Validate_MissingRepoRoot_ReturnsError() + { + var instances = new Dictionary + { + ["test"] = new() { RepoRoot = null, Port = 7147 }, + }; + + var errors = ConfigValidator.Validate(instances, _ => true); + Assert.Single(errors); + Assert.Contains("missing RepoRoot", errors[0]); + } + + [Fact] + public void Validate_NonExistentRepoRoot_ReturnsError() + { + var instances = new Dictionary + { + ["test"] = new() { RepoRoot = @"C:\nonexistent", Port = 7147 }, + }; + + var errors = ConfigValidator.Validate(instances, _ => false); + Assert.Single(errors); + Assert.Contains("does not exist", errors[0]); + } + + [Fact] + public void Validate_InvalidProvider_ReturnsError() + { + var instances = new Dictionary + { + ["test"] = new() { RepoRoot = @"C:\test", Port = 7147, TodoProvider = "mongo" }, + }; + + var errors = ConfigValidator.Validate(instances, _ => true); + Assert.Single(errors); + Assert.Contains("unsupported TodoStorage provider", errors[0]); + } + + [Fact] + public void Validate_SqliteWithoutDataSource_ReturnsError() + { + var instances = new Dictionary + { + ["test"] = new() { RepoRoot = @"C:\test", Port = 7147, TodoProvider = "sqlite", SqliteDataSource = null }, + }; + + var errors = ConfigValidator.Validate(instances, _ => true); + Assert.Single(errors); + Assert.Contains("SqliteDataSource", errors[0]); + } + + [Fact] + public void Validate_ValidConfig_ReturnsNoErrors() + { + var instances = new Dictionary + { + ["default"] = new() { RepoRoot = @"C:\test", Port = 7147, TodoProvider = "yaml" }, + ["alt"] = new() { RepoRoot = @"C:\test", Port = 7148, TodoProvider = "sqlite", SqliteDataSource = "todo.db" }, + }; + + var errors = ConfigValidator.Validate(instances, _ => true); + Assert.Empty(errors); + } +} diff --git a/tests/Build.Tests/GitVersionBumperTests.cs b/tests/Build.Tests/GitVersionBumperTests.cs new file mode 100644 index 0000000..9961011 --- /dev/null +++ b/tests/Build.Tests/GitVersionBumperTests.cs @@ -0,0 +1,69 @@ +namespace NukeBuild.Tests; + +/// +/// TEST-NUKE-002: Verifies GitVersionBumper correctly parses and increments +/// the patch component of GitVersion.yml next-version field. +/// +public sealed class GitVersionBumperTests +{ + private const string SampleContent = """ + mode: ContinuousDelivery + next-version: 0.2.85 + branches: + main: + increment: Patch + """; + + [Fact] + public void ParseVersion_ValidContent_ReturnsMajorMinorPatch() + { + var result = GitVersionBumper.ParseVersion(SampleContent); + Assert.NotNull(result); + Assert.Equal((0, 2, 85), result.Value); + } + + [Fact] + public void ParseVersion_NoNextVersion_ReturnsNull() + { + var result = GitVersionBumper.ParseVersion("mode: ContinuousDelivery\nbranches:\n main:\n"); + Assert.Null(result); + } + + [Fact] + public void BumpPatch_ValidContent_IncrementsPatcn() + { + var result = GitVersionBumper.BumpPatch(SampleContent); + Assert.NotNull(result); + Assert.Equal("0.2.85", result.Value.OldVersion); + Assert.Equal("0.2.86", result.Value.NewVersion); + Assert.Contains("next-version: 0.2.86", result.Value.NewContent); + Assert.DoesNotContain("next-version: 0.2.85", result.Value.NewContent); + } + + [Fact] + public void BumpPatch_NoNextVersion_ReturnsNull() + { + var result = GitVersionBumper.BumpPatch("mode: ContinuousDelivery"); + Assert.Null(result); + } + + [Fact] + public void BumpPatch_PreservesOtherContent() + { + var result = GitVersionBumper.BumpPatch(SampleContent); + Assert.NotNull(result); + Assert.Contains("mode: ContinuousDelivery", result.Value.NewContent); + Assert.Contains("increment: Patch", result.Value.NewContent); + } + + [Theory] + [InlineData("next-version: 1.0.0", 1, 0, 0)] + [InlineData("next-version: 10.20.300", 10, 20, 300)] + [InlineData("next-version: 3.4.5", 3, 4, 5)] + public void ParseVersion_VariousFormats_ParsesCorrectly(string content, int major, int minor, int patch) + { + var result = GitVersionBumper.ParseVersion(content); + Assert.NotNull(result); + Assert.Equal((major, minor, patch), result.Value); + } +} diff --git a/tests/Build.Tests/GlobalUsings.cs b/tests/Build.Tests/GlobalUsings.cs new file mode 100644 index 0000000..b04823c --- /dev/null +++ b/tests/Build.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Xunit; +global using Assert = Xunit.Assert; diff --git a/tests/Build.Tests/MsixHelperTests.cs b/tests/Build.Tests/MsixHelperTests.cs new file mode 100644 index 0000000..1a0839c --- /dev/null +++ b/tests/Build.Tests/MsixHelperTests.cs @@ -0,0 +1,63 @@ +namespace NukeBuild.Tests; + +/// +/// TEST-NUKE-005: Verifies MsixHelper generates valid AppxManifest XML +/// and creates correct placeholder PNG bytes. +/// +public sealed class MsixHelperTests +{ + [Fact] + public void GenerateManifest_ContainsPackageName() + { + var manifest = MsixHelper.GenerateManifest("TestApp", "CN=Test", "1.0.0.0"); + Assert.Contains("Name=\"TestApp\"", manifest); + } + + [Fact] + public void GenerateManifest_ContainsPublisher() + { + var manifest = MsixHelper.GenerateManifest("TestApp", "CN=Test", "1.0.0.0"); + Assert.Contains("Publisher=\"CN=Test\"", manifest); + } + + [Fact] + public void GenerateManifest_ContainsVersion() + { + var manifest = MsixHelper.GenerateManifest("TestApp", "CN=Test", "2.0.1.0"); + Assert.Contains("Version=\"2.0.1.0\"", manifest); + } + + [Fact] + public void GenerateManifest_IsValidXml() + { + var manifest = MsixHelper.GenerateManifest("TestApp", "CN=Test", "1.0.0.0"); + var doc = System.Xml.Linq.XDocument.Parse(manifest); + Assert.NotNull(doc.Root); + } + + [Fact] + public void GenerateManifest_ContainsRunFullTrustCapability() + { + var manifest = MsixHelper.GenerateManifest("TestApp", "CN=Test", "1.0.0.0"); + Assert.Contains("runFullTrust", manifest); + } + + [Fact] + public void GenerateManifest_ContainsExecutable() + { + var manifest = MsixHelper.GenerateManifest("TestApp", "CN=Test", "1.0.0.0"); + Assert.Contains("McpServer.Support.Mcp.exe", manifest); + } + + [Fact] + public void CreatePlaceholderPng_ReturnsValidPngBytes() + { + var bytes = MsixHelper.CreatePlaceholderPng(); + Assert.NotEmpty(bytes); + // PNG magic bytes: 89 50 4E 47 + Assert.Equal(0x89, bytes[0]); + Assert.Equal(0x50, bytes[1]); + Assert.Equal(0x4E, bytes[2]); + Assert.Equal(0x47, bytes[3]); + } +} diff --git a/tests/Build.Tests/TraceabilityValidatorTests.cs b/tests/Build.Tests/TraceabilityValidatorTests.cs new file mode 100644 index 0000000..1851c93 --- /dev/null +++ b/tests/Build.Tests/TraceabilityValidatorTests.cs @@ -0,0 +1,107 @@ +namespace NukeBuild.Tests; + +/// +/// TEST-NUKE-004: Verifies TraceabilityValidator correctly extracts requirement IDs +/// from markdown documents and validates coverage across mapping and matrix files. +/// +public sealed class TraceabilityValidatorTests +{ + [Fact] + public void GetIdsFromHeadings_ExtractsFrIds() + { + string[] lines = ["# Header", "## FR-MCP-001 Some Feature", "## FR-MCP-002 Another Feature", "text"]; + var ids = TraceabilityValidator.GetIdsFromHeadings(lines, + new System.Text.RegularExpressions.Regex(@"^##\s+(FR-[A-Z0-9-]+-\d{3})\b")); + Assert.Equal(2, ids.Count); + Assert.Equal("FR-MCP-001", ids[0]); + Assert.Equal("FR-MCP-002", ids[1]); + } + + [Fact] + public void GetTestIds_ExtractsTestIds() + { + string[] lines = ["TEST-MCP-001 is a test", "and TEST-MCP-002 too", "no test here"]; + var ids = TraceabilityValidator.GetTestIds(lines); + Assert.Equal(2, ids.Count); + Assert.Contains("TEST-MCP-001", ids); + Assert.Contains("TEST-MCP-002", ids); + } + + [Fact] + public void GetMappingFrIds_ExtractsFrIdsFromTable() + { + string[] lines = ["| FR-MCP-001 | TR-MCP-ARCH-001 |", "| FR-MCP-002 | TR-MCP-API-001 |", "| header |"]; + var ids = TraceabilityValidator.GetMappingFrIds(lines); + Assert.Equal(2, ids.Count); + Assert.Equal("FR-MCP-001", ids[0]); + } + + [Fact] + public void ExpandRangeToken_SingleId_ReturnsSelf() + { + var result = TraceabilityValidator.ExpandRangeToken("FR-MCP-001").ToList(); + Assert.Single(result); + Assert.Equal("FR-MCP-001", result[0]); + } + + [Fact] + public void ExpandRangeToken_Range_ExpandsCorrectly() + { + var result = TraceabilityValidator.ExpandRangeToken("FR-MCP-001-003").ToList(); + Assert.Equal(3, result.Count); + Assert.Equal("FR-MCP-001", result[0]); + Assert.Equal("FR-MCP-002", result[1]); + Assert.Equal("FR-MCP-003", result[2]); + } + + [Fact] + public void GetMatrixIds_ExpandsRanges() + { + string[] lines = ["| FR-MCP-001-003 | Planned |", "| TR-MCP-ARCH-001 | Done |"]; + var ids = TraceabilityValidator.GetMatrixIds(lines); + Assert.Contains("FR-MCP-001", ids); + Assert.Contains("FR-MCP-002", ids); + Assert.Contains("FR-MCP-003", ids); + Assert.Contains("TR-MCP-ARCH-001", ids); + } + + [Fact] + public void Validate_AllPresent_ReturnsNoMissing() + { + string[] fr = ["## FR-MCP-001 Feature"]; + string[] tr = ["## TR-MCP-ARCH-001 Arch"]; + string[] test = ["TEST-MCP-001 test"]; + string[] mapping = ["| FR-MCP-001 | TR-MCP-ARCH-001 |"]; + string[] matrix = ["| FR-MCP-001 | Done |", "| TR-MCP-ARCH-001 | Done |", "| TEST-MCP-001 | Done |"]; + + var result = TraceabilityValidator.Validate(fr, tr, test, mapping, matrix); + Assert.Empty(result.MissingFrInMapping); + Assert.Empty(result.MissingFrInMatrix); + Assert.Empty(result.MissingTrInMatrix); + Assert.Empty(result.MissingTestInMatrix); + } + + [Fact] + public void Validate_MissingFrInMapping_ReportsCorrectly() + { + string[] fr = ["## FR-MCP-001 Feature", "## FR-MCP-002 Feature2"]; + string[] tr = []; + string[] test = []; + string[] mapping = ["| FR-MCP-001 | TR-MCP-ARCH-001 |"]; + string[] matrix = ["| FR-MCP-001 | Done |", "| FR-MCP-002 | Done |"]; + + var result = TraceabilityValidator.Validate(fr, tr, test, mapping, matrix); + Assert.Single(result.MissingFrInMapping); + Assert.Equal("FR-MCP-002", result.MissingFrInMapping[0]); + } + + [Fact] + public void ValidationResult_HasFrErrors_TrueWhenMissing() + { + var result = new TraceabilityValidator.ValidationResult + { + MissingFrInMapping = ["FR-MCP-001"], + }; + Assert.True(result.HasFrErrors); + } +} diff --git a/tests/McpServer.McpAgent.Tests/McpHostedAgentAdapterTests.cs b/tests/McpServer.McpAgent.Tests/McpHostedAgentAdapterTests.cs index e52e64a..523ecdd 100644 --- a/tests/McpServer.McpAgent.Tests/McpHostedAgentAdapterTests.cs +++ b/tests/McpServer.McpAgent.Tests/McpHostedAgentAdapterTests.cs @@ -8,6 +8,7 @@ using McpServer.McpAgent.Todo; using McpServer.Client; using McpServer.Client.Models; +using McpServer.Repl.Core; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -441,8 +442,12 @@ private static (McpHostedAgent HostedAgent, RecordingMcpHttpMessageHandler Handl WorkspacePath = @"E:\github\McpServer", }); var identifiers = new McpSessionIdentifierFactory(options, timeProvider); - var sessionLog = new SessionLogWorkflow(client, identifiers, timeProvider); + var sessionLog = new McpServer.McpAgent.SessionLog.SessionLogWorkflow(client, identifiers, timeProvider); var todo = new TodoWorkflow(client); + var requirements = new RequirementsWorkflow(client.Requirements); + var clientPassthrough = new GenericClientPassthrough(client); + var replSessionLogAdapter = new SessionLogClientAdapter(client.SessionLog); + var replSessionLog = new McpServer.Repl.Core.SessionLogWorkflow(replSessionLogAdapter, timeProvider); var serviceProvider = new ServiceCollection().BuildServiceProvider(); return ( @@ -458,6 +463,9 @@ private static (McpHostedAgent HostedAgent, RecordingMcpHttpMessageHandler Handl options, sessionLog, todo, + requirements, + clientPassthrough, + replSessionLog, serviceProvider), handler); }