Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/`

281 changes: 241 additions & 40 deletions scripts/Validate-McpConfig.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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."
33 changes: 27 additions & 6 deletions src/McpServer.Client/Models/ContextModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}

/// <summary>GraphRAG citation entry.</summary>
public sealed class GraphRagCitation
Expand Down Expand Up @@ -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
7 changes: 7 additions & 0 deletions src/McpServer.GraphRag/Models/GraphRagModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}

/// <summary>Citation payload from GraphRAG query responses.</summary>
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ public async Task<GraphRagBackendIndexResult> 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)
Expand Down
Loading
Loading