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
1 change: 1 addition & 0 deletions PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,5 @@
<ItemGroup>
<EmbeddedResource Include="Prompts\Templates\*.md" />
</ItemGroup>

</Project>
173 changes: 173 additions & 0 deletions PowerShell.MCP/OutputTruncationHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
using System.Diagnostics;

namespace PowerShell.MCP;

/// <summary>
/// Truncates large command output to preserve AI context window budget.
/// Saves the full output to a temp file so the AI can retrieve it via Read tool if needed.
/// </summary>
public static class OutputTruncationHelper
{
internal const int TruncationThreshold = 15_000;
internal const int PreviewHeadSize = 1000;
internal const int PreviewTailSize = 1000;

// The threshold must exceed the combined preview sizes; otherwise the head
// and tail slices overlap, producing duplicated content in the preview.
private static readonly bool _ = Validate();
private static bool Validate()
{
Debug.Assert(
TruncationThreshold > PreviewHeadSize + PreviewTailSize,
$"TruncationThreshold ({TruncationThreshold}) must be greater than " +
$"PreviewHeadSize + PreviewTailSize ({PreviewHeadSize + PreviewTailSize}) " +
"to avoid overlapping head/tail previews.");
return true;
}
internal const string OutputDirectoryName = "PowerShell.MCP.Output";
internal const int MaxFileAgeMinutes = 120;
internal const int NewlineScanLimit = 200;

/// <summary>
/// Returns the output unchanged if within threshold, otherwise saves the full content
/// to a temp file and returns a head+tail preview with the file path.
/// </summary>
public static string TruncateIfNeeded(string output, string? outputDirectory = null)
{
if (output.Length <= TruncationThreshold)
return output;

// Compute newline-aligned head boundary
var headEnd = FindHeadBoundary(output, PreviewHeadSize);
var head = output[..headEnd];

// Compute newline-aligned tail boundary
var tailStart = FindTailBoundary(output, PreviewTailSize);
var tail = output[tailStart..];

var omitted = output.Length - head.Length - tail.Length;

var filePath = SaveOutputToFile(output, outputDirectory);

var sb = new System.Text.StringBuilder();

if (filePath != null)
{
sb.AppendLine($"Output too large ({output.Length} characters). Full output saved to: {filePath}");
sb.AppendLine($"Use invoke_expression('Show-TextFiles \"{filePath}\" -Contains \"search term\"') or -Pattern \"regex\" to search the output.");
}
else
{
// Disk save failed — still provide the preview without a file path
sb.AppendLine($"Output too large ({output.Length} characters). Could not save full output to file.");
}

sb.AppendLine();
sb.AppendLine("--- Preview (first ~1000 chars) ---");
sb.AppendLine(head);
sb.AppendLine($"--- truncated ({omitted} chars omitted) ---");
sb.AppendLine("--- Preview (last ~1000 chars) ---");
sb.Append(tail);

return sb.ToString();
}

/// <summary>
/// Saves the full output to a timestamped temp file and triggers opportunistic cleanup.
/// Returns the file path on success, null on failure.
/// </summary>
internal static string? SaveOutputToFile(string output, string? outputDirectory = null)
{
try
{
var directory = outputDirectory
?? Path.Combine(Path.GetTempPath(), OutputDirectoryName);

Directory.CreateDirectory(directory);

var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff");
var random = Path.GetRandomFileName();
var fileName = $"pwsh_output_{timestamp}_{random}.txt";
var filePath = Path.Combine(directory, fileName);

File.WriteAllText(filePath, output);

// Opportunistic cleanup — never let it block or fail the save
CleanupOldOutputFiles(directory);

return filePath;
}
catch
{
return null;
}
}

/// <summary>
/// Deletes pwsh_output_*.txt files older than <see cref="MaxFileAgeMinutes"/> minutes.
/// Each deletion is individually guarded so a locked file does not prevent other cleanups.
/// </summary>
internal static void CleanupOldOutputFiles(string? directory = null)
{
try
{
var dir = directory
?? Path.Combine(Path.GetTempPath(), OutputDirectoryName);

if (!Directory.Exists(dir))
return;

var cutoff = DateTime.Now.AddMinutes(-MaxFileAgeMinutes);

foreach (var file in Directory.EnumerateFiles(dir, "pwsh_output_*.txt"))
{
try
{
if (File.GetLastWriteTime(file) < cutoff)
File.Delete(file);
}
catch (IOException)
{
// Another thread may be writing — safe to ignore
}
}
}
catch
{
// Directory enumeration itself failed — nothing to clean up
}
}

/// <summary>
/// Finds a head cut position aligned to the nearest preceding newline within scan limit.
/// </summary>
private static int FindHeadBoundary(string output, int nominalSize)
{
if (nominalSize >= output.Length)
return output.Length;

// Search backward from nominalSize for a newline, up to NewlineScanLimit chars
var searchStart = Math.Max(0, nominalSize - NewlineScanLimit);
var lastNewline = output.LastIndexOf('\n', nominalSize - 1, nominalSize - searchStart);

// Cut after the newline to keep complete lines in the head
return lastNewline >= 0 ? lastNewline + 1 : nominalSize;
}

/// <summary>
/// Finds a tail start position aligned to the nearest following newline within scan limit.
/// </summary>
private static int FindTailBoundary(string output, int nominalSize)
{
var nominalStart = output.Length - nominalSize;
if (nominalStart <= 0)
return 0;

// Search forward from nominalStart for a newline, up to NewlineScanLimit chars
var searchEnd = Math.Min(output.Length, nominalStart + NewlineScanLimit);
var nextNewline = output.IndexOf('\n', nominalStart, searchEnd - nominalStart);

// Start at the character after the newline to begin on a fresh line
return nextNewline >= 0 ? nextNewline + 1 : nominalStart;
}
}
4 changes: 4 additions & 0 deletions PowerShell.MCP/PowerShell.MCP.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
<NoWarn>$(NoWarn);NETSDK1206</NoWarn>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="PowerShell.MCP.Tests" />
</ItemGroup>

<ItemGroup>
<None Remove="Resources\MCPLocationProvider.ps1" />
<None Remove="Resources\MCPPollingEngine.ps1" />
Expand Down
2 changes: 1 addition & 1 deletion PowerShell.MCP/Resources/MCPPollingEngine.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@ if (-not (Test-Path Variable:global:McpTimer)) {
if ($null -eq $mcpOutput) {
$mcpOutput = "Command execution completed"
}
[PowerShell.MCP.Services.PowerShellCommunication]::NotifyResultReady($mcpOutput)
[PowerShell.MCP.Services.PowerShellCommunication]::NotifySilentResultReady($mcpOutput)
}
}
} | Out-Null
Expand Down
13 changes: 12 additions & 1 deletion PowerShell.MCP/Services/PowerShellCommunication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,18 @@ public static void NotifyResultReady(string result)
// Capture cache flag before AddToCache resets it
_resultShouldCache = ExecutionState.ShouldCacheOutput;

// Always add to cache
// Truncate large output before caching to reduce pipe transfer and memory overhead
var output = OutputTruncationHelper.TruncateIfNeeded(result);
ExecutionState.AddToCache(output);
ExecutionState.CompleteExecution();
_resultReadyEvent.Set();
}

/// <summary>
/// Silent command completed — no truncation (internal use, small known outputs)
/// </summary>
public static void NotifySilentResultReady(string result)
{
ExecutionState.AddToCache(result);
ExecutionState.CompleteExecution();
_resultReadyEvent.Set();
Expand Down
Loading