diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 5d703c83..86c52391 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -172,7 +172,7 @@ The sample host: - supports configurable response verbosity through `SampleHost:Verbosity` or `MCP_AGENT_VERBOSITY` - supports configurable OpenAI client network timeout and retry count through `SampleHost:Model:NetworkTimeoutSeconds`, `SampleHost:Model:MaxRetries`, `OPENAI_NETWORK_TIMEOUT_SECONDS`, and `OPENAI_MAX_RETRIES` - lets you switch verbosity live with `/v 1`, `/v 2`, or `/v 3` -- routes lines prefixed with `! ` directly into the hosted local PowerShell session while leaving other lines as normal chat prompts +- routes lines prefixed with `!` plus a space directly into the hosted local PowerShell session while leaving other lines as normal chat prompts - logs chat turns through the hosted session-log workflow - exposes repository tools, `mcp_desktop_launch` for authenticated local program execution through MCP Server, `mcp_powershell_session_*` for model-facing stateful PowerShell execution, and `IMcpHostedAgent.PowerShellSessions` for host-facing direct PowerShell execution @@ -556,4 +556,3 @@ Invoke-RestMethod -Method Post -Uri "http://localhost:7147/mcpserver/workspace" - FAQ: `docs/FAQ.md` - Context docs: `docs/context/` - Tunnel runbooks: `docs/Operations/` - diff --git a/scripts/Validate-McpConfig.ps1 b/scripts/Validate-McpConfig.ps1 index 068df8b4..e36a20da 100644 --- a/scripts/Validate-McpConfig.ps1 +++ b/scripts/Validate-McpConfig.ps1 @@ -2,35 +2,236 @@ .SYNOPSIS Validates MCP appsettings instance configuration. #> -[CmdletBinding()] -param( - [string]$ConfigPath = "src/McpServer.Support.Mcp/appsettings.json" -) - -$ErrorActionPreference = "Stop" - -if (-not (Test-Path $ConfigPath)) { - throw "Config file not found: $ConfigPath" -} - -$json = Get-Content -Raw -Path $ConfigPath | ConvertFrom-Json -if (-not $json.Mcp) { - throw "Missing 'Mcp' section." -} - -$instances = $json.Mcp.Instances -if (-not $instances) { - Write-Host "No Mcp:Instances configured. Validation passed." - exit 0 -} - -$ports = @{} -$instances.PSObject.Properties | ForEach-Object { - $name = $_.Name - $instance = $_.Value - - if (-not $instance.RepoRoot) { - throw "Instance '$name' missing RepoRoot." +[CmdletBinding()] +param( + [string]$ConfigPath = "" +) + +$ErrorActionPreference = "Stop" +$yamlKeyPattern = '[A-Za-z0-9_][A-Za-z0-9_\-]*' + +function ConvertFrom-YamlScalar { + <# + .SYNOPSIS + Normalizes a simple YAML scalar value for validation. + + .DESCRIPTION + Trims surrounding whitespace and removes matching single- or double-quote delimiters + when both ends use the same quote character. Mismatched quote pairs are left unchanged + so malformed values are not silently rewritten during validation. + #> + param( + [string]$Value + ) + + $trimmed = $Value.Trim() + if ($trimmed.Length -ge 2 -and ( + ($trimmed[0] -eq "'" -and $trimmed[$trimmed.Length - 1] -eq "'") -or + ($trimmed[0] -eq '"' -and $trimmed[$trimmed.Length - 1] -eq '"'))) { + return $trimmed.Substring(1, $trimmed.Length - 2) + } + + return $trimmed +} + +function Get-McpInstancesFromYaml { + <# + .SYNOPSIS + Extracts the Mcp:Instances block from the repository YAML settings file. + + .DESCRIPTION + Parses the checked-in appsettings YAML using the repository's current indentation pattern + so the validation script can run in CI without depending on an external YAML module. + The return value includes a HasMcp flag and an ordered dictionary of instance settings + containing RepoRoot, Port, and TodoStorage fields needed by this validator. + #> + param( + [string]$Path + ) + + $lines = Get-Content -Path $Path + $hasMcp = $false + $instances = [ordered]@{} + $inInstances = $false + $currentInstance = $null + $inTodoStorage = $false + + foreach ($rawLine in $lines) { + $line = $rawLine.TrimEnd() + if ([string]::IsNullOrWhiteSpace($line) -or $line.TrimStart().StartsWith('#')) { + continue + } + + if ($line -match '^Mcp:\s*$') { + $hasMcp = $true + continue + } + + if (-not $hasMcp) { + continue + } + + if ($line -match '^ Instances:\s*$') { + $inInstances = $true + $currentInstance = $null + $inTodoStorage = $false + continue + } + + if (-not $inInstances) { + continue + } + + if ($line -match "^ ${yamlKeyPattern}:\s*$") { + # A sibling key under Mcp means the Instances block has ended. + break + } + + if ($line -match "^ (${yamlKeyPattern}):\s*$") { + $currentInstance = $Matches[1] + $instances[$currentInstance] = [ordered]@{ + RepoRoot = $null + Port = $null + TodoStorage = [ordered]@{ + Provider = $null + SqliteDataSource = $null + } + } + $inTodoStorage = $false + continue + } + + if ($null -eq $currentInstance) { + continue + } + + if ($line -match '^ TodoStorage:\s*$') { + $inTodoStorage = $true + continue + } + + if ($line -match '^ (RepoRoot|Port):\s*') { + $inTodoStorage = $false + } + + if ($line -match '^ RepoRoot:\s*(.+)$') { + $instances[$currentInstance].RepoRoot = ConvertFrom-YamlScalar $Matches[1] + continue + } + + if ($line -match '^ Port:\s*(.+)$') { + $instances[$currentInstance].Port = ConvertFrom-YamlScalar $Matches[1] + continue + } + + if ($inTodoStorage -and $line -match '^ Provider:\s*(.+)$') { + $instances[$currentInstance].TodoStorage.Provider = ConvertFrom-YamlScalar $Matches[1] + continue + } + + if ($inTodoStorage -and $line -match '^ SqliteDataSource:\s*(.+)$') { + $instances[$currentInstance].TodoStorage.SqliteDataSource = ConvertFrom-YamlScalar $Matches[1] + continue + } + } + + return @{ + HasMcp = $hasMcp + Instances = $instances + } +} + +function ConvertTo-McpInstanceMap { + <# + .SYNOPSIS + Normalizes parsed instance settings into a consistent ordered dictionary. + + .DESCRIPTION + Converts either JSON-derived PSCustomObject instances or the YAML parser output into + the same RepoRoot/Port/TodoStorage shape so the validation logic can iterate a single + data structure regardless of the source file format. + #> + param( + [object]$Instances + ) + + $instanceMap = [ordered]@{} + if ($null -eq $Instances) { + return $instanceMap + } + + $entries = if ($Instances -is [System.Collections.IDictionary]) { + $Instances.GetEnumerator() | Sort-Object Name + } + else { + $Instances.PSObject.Properties | ForEach-Object { + [pscustomobject]@{ + Name = $_.Name + Value = $_.Value + } + } | Sort-Object Name + } + + foreach ($entry in $entries) { + $instanceMap[$entry.Name] = [ordered]@{ + RepoRoot = $entry.Value.RepoRoot + Port = $entry.Value.Port + TodoStorage = [ordered]@{ + Provider = $entry.Value.TodoStorage.Provider + SqliteDataSource = $entry.Value.TodoStorage.SqliteDataSource + } + } + } + + return $instanceMap +} + +if ([string]::IsNullOrWhiteSpace($ConfigPath)) { + $candidatePaths = @( + "src/McpServer.Support.Mcp/appsettings.yaml", + "src/McpServer.Support.Mcp/appsettings.yml", + "src/McpServer.Support.Mcp/appsettings.json" + ) + $ConfigPath = $candidatePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 +} + +if (-not (Test-Path $ConfigPath)) { + throw "Config file not found: $ConfigPath" +} + +$extension = [System.IO.Path]::GetExtension($ConfigPath) +$config = switch ($extension.ToLowerInvariant()) { + ".yaml" { Get-McpInstancesFromYaml -Path $ConfigPath } + ".yml" { Get-McpInstancesFromYaml -Path $ConfigPath } + ".json" { + $json = Get-Content -Raw -Path $ConfigPath | ConvertFrom-Json + @{ + HasMcp = $null -ne $json.Mcp + Instances = ConvertTo-McpInstanceMap -Instances $json.Mcp.Instances + } + } + default { throw "Unsupported config format '$extension' for '$ConfigPath'." } +} + +if (-not $config.HasMcp) { + throw "Missing 'Mcp' section." +} + +$instances = $config.Instances +if (-not $instances) { + Write-Host "No Mcp:Instances configured. Validation passed." + exit 0 +} + +$instanceEntries = $instances.GetEnumerator() | Sort-Object Name + +$ports = @{} +$instanceEntries | ForEach-Object { + $name = $_.Name + $instance = $_.Value + + if (-not $instance.RepoRoot) { + throw "Instance '$name' missing RepoRoot." } $resolvedRoot = [System.IO.Path]::GetFullPath([string]$instance.RepoRoot) if (-not (Test-Path -Path $resolvedRoot -PathType Container)) { @@ -58,14 +259,14 @@ $instances.PSObject.Properties | ForEach-Object { if ($provider -eq "sqlite") { $sqliteDataSource = "" - if ($instance.TodoStorage -and $instance.TodoStorage.SqliteDataSource) { - $sqliteDataSource = [string]$instance.TodoStorage.SqliteDataSource - } - if ([string]::IsNullOrWhiteSpace($sqliteDataSource)) { - throw "Instance '$name' provider sqlite requires TodoStorage.SqliteDataSource." - } - } -} - -$instanceCount = @($instances.PSObject.Properties).Count -Write-Host "MCP config validation passed for $instanceCount instances." + if ($instance.TodoStorage -and $instance.TodoStorage.SqliteDataSource) { + $sqliteDataSource = [string]$instance.TodoStorage.SqliteDataSource + } + if ([string]::IsNullOrWhiteSpace($sqliteDataSource)) { + throw "Instance '$name' provider sqlite requires TodoStorage.SqliteDataSource." + } + } +} + +$instanceCount = @($instanceEntries).Count +Write-Host "MCP config validation passed for $instanceCount instances." diff --git a/src/McpServer.Client/Models/ContextModels.cs b/src/McpServer.Client/Models/ContextModels.cs index fea20f62..b1e5bb18 100644 --- a/src/McpServer.Client/Models/ContextModels.cs +++ b/src/McpServer.Client/Models/ContextModels.cs @@ -271,9 +271,24 @@ public sealed class GraphRagStatusResult [JsonPropertyName("lastIndexedDocumentCount")] public int? LastIndexedDocumentCount { get; set; } - [JsonPropertyName("backend")] - public string Backend { get; set; } = string.Empty; -} + [JsonPropertyName("backend")] + public string Backend { get; set; } = string.Empty; + + [JsonPropertyName("indexCorpus")] + public string IndexCorpus { get; set; } = string.Empty; + + [JsonPropertyName("queryCorpus")] + public string QueryCorpus { get; set; } = string.Empty; + + [JsonPropertyName("inputPath")] + public string InputPath { get; set; } = string.Empty; + + [JsonPropertyName("inputDocumentCount")] + public int InputDocumentCount { get; set; } + + [JsonPropertyName("visibilityNote")] + public string? VisibilityNote { get; set; } +} /// GraphRAG citation entry. public sealed class GraphRagCitation @@ -327,8 +342,14 @@ public sealed class GraphRagQueryResult [JsonPropertyName("failureCode")] public string? FailureCode { get; set; } - [JsonPropertyName("backend")] - public string Backend { get; set; } = string.Empty; -} + [JsonPropertyName("backend")] + public string Backend { get; set; } = string.Empty; + + [JsonPropertyName("queryCorpus")] + public string QueryCorpus { get; set; } = string.Empty; + + [JsonPropertyName("visibilityNote")] + public string? VisibilityNote { get; set; } +} #pragma warning restore CS1591 diff --git a/src/McpServer.GraphRag/Models/GraphRagModels.cs b/src/McpServer.GraphRag/Models/GraphRagModels.cs index f6d1d292..0b60eb99 100644 --- a/src/McpServer.GraphRag/Models/GraphRagModels.cs +++ b/src/McpServer.GraphRag/Models/GraphRagModels.cs @@ -40,6 +40,11 @@ public sealed class GraphRagStatusResponse public long? LastIndexDurationMs { get; set; } public int? LastIndexedDocumentCount { get; set; } public string Backend { get; set; } = "internal-fallback"; + public string IndexCorpus { get; set; } = "graphrag-input"; + public string QueryCorpus { get; set; } = "context-search"; + public string InputPath { get; set; } = string.Empty; + public int InputDocumentCount { get; set; } + public string? VisibilityNote { get; set; } } /// Citation payload from GraphRAG query responses. @@ -66,6 +71,8 @@ public sealed class GraphRagQueryResponse public string? FallbackReason { get; set; } public string? FailureCode { get; set; } public string Backend { get; set; } = "internal-fallback"; + public string QueryCorpus { get; set; } = "context-search"; + public string? VisibilityNote { get; set; } } #pragma warning restore CS1591 diff --git a/src/McpServer.GraphRag/Services/ExternalCommandGraphRagBackendAdapter.cs b/src/McpServer.GraphRag/Services/ExternalCommandGraphRagBackendAdapter.cs index fc08d3ca..c03ae8f2 100644 --- a/src/McpServer.GraphRag/Services/ExternalCommandGraphRagBackendAdapter.cs +++ b/src/McpServer.GraphRag/Services/ExternalCommandGraphRagBackendAdapter.cs @@ -85,7 +85,9 @@ public async Task IndexAsync(GraphRagBackendExecutio FallbackUsed = false, FallbackReason = null, FailureCode = null, - Backend = AdapterName + Backend = AdapterName, + QueryCorpus = "graphrag-backend", + VisibilityNote = null }; } catch (Exception ex) when (ex is not OperationCanceledException) diff --git a/src/McpServer.GraphRag/Services/GraphRagService.cs b/src/McpServer.GraphRag/Services/GraphRagService.cs index 61d5e38e..8a22331f 100644 --- a/src/McpServer.GraphRag/Services/GraphRagService.cs +++ b/src/McpServer.GraphRag/Services/GraphRagService.cs @@ -50,6 +50,7 @@ public async Task GetStatusAsync(CancellationToken cance { var workspacePath = ResolveWorkspacePath(); var graphRoot = ResolveGraphRoot(workspacePath); + var inputPath = Path.Combine(graphRoot, "input"); var persisted = await TryReadStatusAsync(graphRoot, cancellationToken).ConfigureAwait(false); var backend = SelectBackend(); var initialized = HasInitializedStructure(graphRoot); @@ -57,6 +58,10 @@ public async Task GetStatusAsync(CancellationToken cance var isIndexedByArtifact = IsReadyArtifactPresent(graphRoot); var isIndexed = persisted?.IsIndexed == true && isIndexedByArtifact; var backendAvailabilityError = GetBackendAvailabilityError(backend); + var inputDocumentCount = Directory.Exists(inputPath) + ? Directory.EnumerateFiles(inputPath, "*", SearchOption.AllDirectories).Count() + : 0; + var isInternalFallback = string.Equals(backend.AdapterName, "internal-fallback", StringComparison.OrdinalIgnoreCase); return new GraphRagStatusResponse { @@ -75,7 +80,14 @@ public async Task GetStatusAsync(CancellationToken cance ArtifactVersion = persisted?.ArtifactVersion ?? _options.ArtifactVersion, LastIndexDurationMs = persisted?.LastIndexDurationMs, LastIndexedDocumentCount = persisted?.LastIndexedDocumentCount, - Backend = backend.AdapterName + Backend = backend.AdapterName, + IndexCorpus = "graphrag-input", + QueryCorpus = isInternalFallback ? "context-search" : "graphrag-backend", + InputPath = inputPath, + InputDocumentCount = inputDocumentCount, + VisibilityNote = isInternalFallback + ? "internal-fallback indexes files under GraphRAG input but query results come from context-search." + : null }; } @@ -268,7 +280,9 @@ public async Task QueryAsync(GraphRagQueryRequest request FallbackUsed = fallbackUsed, FallbackReason = fallbackReason, FailureCode = fallbackUsed ? "query_fallback" : null, - Backend = SelectBackend().AdapterName + Backend = SelectBackend().AdapterName, + QueryCorpus = "context-search", + VisibilityNote = "Fallback query uses context-search chunks; GraphRAG input visibility depends on ingestion into context-search." }; } diff --git a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/GraphRagControllerTests.cs b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/GraphRagControllerTests.cs index fd0d61a8..c2fc2315 100644 --- a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/GraphRagControllerTests.cs +++ b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/GraphRagControllerTests.cs @@ -25,6 +25,10 @@ public async Task Status_ReturnsOk() using var doc = JsonDocument.Parse(json); Assert.True(doc.RootElement.TryGetProperty("enabled", out _)); Assert.True(doc.RootElement.TryGetProperty("graphRoot", out _)); + Assert.True(doc.RootElement.TryGetProperty("indexCorpus", out _)); + Assert.True(doc.RootElement.TryGetProperty("queryCorpus", out _)); + Assert.True(doc.RootElement.TryGetProperty("inputPath", out _)); + Assert.True(doc.RootElement.TryGetProperty("inputDocumentCount", out _)); } [Fact] @@ -57,4 +61,20 @@ public async Task Query_WithInvalidMaxChunks_ReturnsBadRequest() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + + [Fact] + public async Task Query_ReturnsCorpusDiagnostics() + { + var response = await _client.PostAsJsonAsync(new Uri("/mcpserver/graphrag/query", UriKind.Relative), new + { + query = "auth", + maxChunks = 5 + }).ConfigureAwait(true); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(true); + using var doc = JsonDocument.Parse(json); + Assert.True(doc.RootElement.TryGetProperty("queryCorpus", out _)); + Assert.True(doc.RootElement.TryGetProperty("visibilityNote", out _)); + } } diff --git a/tests/McpServer.Support.Mcp.Tests/Services/GraphRagServiceTests.cs b/tests/McpServer.Support.Mcp.Tests/Services/GraphRagServiceTests.cs index 248b8855..6501feb8 100644 --- a/tests/McpServer.Support.Mcp.Tests/Services/GraphRagServiceTests.cs +++ b/tests/McpServer.Support.Mcp.Tests/Services/GraphRagServiceTests.cs @@ -62,6 +62,25 @@ public async Task QueryAsync_WhenDisabled_ReturnsFallbackReason() Assert.True(response.FallbackUsed); Assert.Equal("graphrag_disabled", response.FallbackReason); + Assert.Equal("context-search", response.QueryCorpus); + } + + [Fact] + public async Task Status_InternalFallback_ReportsCorpusAndInputDiagnostics() + { + var sut = CreateSut(enabled: true); + var initialized = await sut.InitializeAsync().ConfigureAwait(true); + var localDocPath = Path.Combine(initialized.GraphRoot, "input", "docs", "prg", "Commodore_64_Programmers_Reference_Guide.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(localDocPath)!); + await File.WriteAllTextAsync(localDocPath, "Video Bank Selection").ConfigureAwait(true); + + var status = await sut.GetStatusAsync().ConfigureAwait(true); + + Assert.Equal("graphrag-input", status.IndexCorpus); + Assert.Equal("context-search", status.QueryCorpus); + Assert.Equal(Path.Combine(status.GraphRoot, "input"), status.InputPath); + Assert.Equal(1, status.InputDocumentCount); + Assert.Contains("internal-fallback", status.VisibilityNote, StringComparison.OrdinalIgnoreCase); } [Fact]