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]