Skip to content
Open
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: 2 additions & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"sdk": {
"version": "10.0.105"
"version": "10.0.105",
"rollForward": "latestFeature"
}
}
9 changes: 9 additions & 0 deletions src/TALXIS.CLI.MCP/CliSubprocessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,15 @@ public CliSubprocessResult(int exitCode, string output)
Output = output;
}

/// <summary>Creates a result with explicit stdout/stderr-derived diagnostic fields.</summary>
internal CliSubprocessResult(int exitCode, string output, string lastErrors, string fullLog)
{
ExitCode = exitCode;
Output = output;
LastErrors = lastErrors;
FullLog = fullLog;
}

/// <summary>Creates a result from a streaming output handler (uses stdout content as output).</summary>
public CliSubprocessResult(int exitCode, ISubprocessOutputHandler handler)
{
Expand Down
127 changes: 127 additions & 0 deletions src/TALXIS.CLI.MCP/McpToolResultFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using TALXIS.CLI.Logging;

namespace TALXIS.CLI.MCP;

/// <summary>
/// Builds MCP tool results and exposes failed tool diagnostics as fetchable resources.
/// </summary>
internal sealed class McpToolResultFactory
{
private readonly ToolLogStore _toolLogStore;

public McpToolResultFactory(ToolLogStore toolLogStore)
{
_toolLogStore = toolLogStore;
}

public CallToolResult Build(string toolName, CliSubprocessResult result)
{
if (result.ExitCode == 0)
{
return new CallToolResult
{
Content = [new TextContentBlock { Text = result.Output }]
};
}

string summary = BuildFailureSummary(toolName, result.Output, result.LastErrors, result.ExitCode);
string diagnosticsUri = _toolLogStore.StoreFailure(
toolName,
result.ExitCode,
summary,
result.LastErrors,
result.FullLog);

return BuildFailureResult(toolName, summary, diagnosticsUri);
}

public CallToolResult BuildExceptionResult(string toolName, Exception exception)
{
string summary = string.IsNullOrWhiteSpace(exception.Message)
? $"Tool '{toolName}' failed before execution completed."
: LogRedactionFilter.Redact(exception.Message);
string diagnosticsUri = _toolLogStore.StoreFailure(
toolName,
-1,
summary,
summary,
LogRedactionFilter.Redact(exception.ToString()));

return BuildFailureResult(toolName, summary, diagnosticsUri);
}

public List<Resource> BuildResources()
{
return _toolLogStore.ListAll().Select(e => new Resource
{
Uri = e.Uri,
Name = $"Failure details: {e.Entry.ToolName}",
Description = BuildResourceDescription(e.Entry),
MimeType = "application/json"
}).ToList();
}

public ReadResourceResult ReadResource(string uri)
{
if (!_toolLogStore.TryGet(uri, out var entry) || entry is null)
{
throw new McpException($"Resource not found: {uri}");
}

return new ReadResourceResult
{
Contents = [new TextResourceContents { Uri = uri, MimeType = "application/json", Text = entry.ToJson() }]
};
}

private static CallToolResult BuildFailureResult(string toolName, string summary, string diagnosticsUri)
{
return new CallToolResult
{
IsError = true,
Content =
[
new TextContentBlock { Text = summary },
new ResourceLinkBlock
{
Uri = diagnosticsUri,
Name = $"Failure details for {toolName}",
Description = "Fetch structured diagnostics for this failed tool call via resources/read.",
MimeType = "application/json"
}
]
};
}

private static string BuildFailureSummary(string toolName, string output, string lastErrors, int exitCode)
{
if (!string.IsNullOrWhiteSpace(output))
{
return output.Trim();
}

var firstErrorLine = lastErrors
.Split([Environment.NewLine, "\n"], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault();

return !string.IsNullOrWhiteSpace(firstErrorLine)
? firstErrorLine
: $"Tool '{toolName}' failed with exit code {exitCode}.";
}

private static string BuildResourceDescription(ToolLogStore.LogEntry entry)
{
var singleLineSummary = entry.Summary
.ReplaceLineEndings(" ")
.Trim();

if (singleLineSummary.Length > 160)
{
singleLineSummary = singleLineSummary[..157] + "...";
}

return $"{singleLineSummary} Use resources/read to retrieve detailed diagnostics.";
}
}
80 changes: 10 additions & 70 deletions src/TALXIS.CLI.MCP/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
RootsService? rootsService = null;
IHostApplicationLifetime? appLifetime = null;

// In-memory store for tool execution logs, exposed as MCP resources
// In-memory store for structured failure details, exposed as MCP resources
var toolLogStore = new ToolLogStore();
var toolResultFactory = new McpToolResultFactory(toolLogStore);

// Per-output-path lock for workspace_component_create to prevent concurrent writes to the same project
var workspaceOutputLocks = new System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim>(StringComparer.OrdinalIgnoreCase);
Expand Down Expand Up @@ -340,15 +341,15 @@ async Task<CallToolResult> ExecuteCliToolAsync(

mcpLogger.LogInformation("Tool completed: {ToolName} (exit code {ExitCode})", toolName, result.ExitCode);

return BuildToolResult(toolName, result);
return toolResultFactory.Build(toolName, result);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
return new CallToolResult { Content = [new TextContentBlock { Text = LogRedactionFilter.Redact(ex.ToString()) }], IsError = true };
return toolResultFactory.BuildExceptionResult(toolName, ex);
}
finally
{
Expand Down Expand Up @@ -423,7 +424,7 @@ async ValueTask<CallToolResult> ExecuteAsTaskAsync(

mcpLogger.LogInformation("Task completed: {ToolName} (exit code {ExitCode}, taskId: {TaskId})", toolName, result.ExitCode, mcpTask.TaskId);

var callToolResult = BuildToolResult(toolName, result);
var callToolResult = toolResultFactory.Build(toolName, result);

var finalStatus = result.ExitCode != 0 ? McpTaskStatus.Failed : McpTaskStatus.Completed;
var resultElement = System.Text.Json.JsonSerializer.SerializeToElement(callToolResult);
Expand All @@ -446,11 +447,7 @@ async ValueTask<CallToolResult> ExecuteAsTaskAsync(
{
try
{
var errorResult = new CallToolResult
{
IsError = true,
Content = [new TextContentBlock { Text = $"Task execution failed: {LogRedactionFilter.Redact(ex.ToString())}" }]
};
var errorResult = toolResultFactory.BuildExceptionResult(toolName, ex);
var errorElement = System.Text.Json.JsonSerializer.SerializeToElement(errorResult);
var failedTask = await taskStore.StoreTaskResultAsync(
mcpTask.TaskId, McpTaskStatus.Failed, errorElement, sessionId, CancellationToken.None);
Expand Down Expand Up @@ -760,75 +757,18 @@ async Task<CallToolResult> ExecuteMcpSpecificToolWithCapturedOutputAsync(Type co
}
}

// Build a CallToolResult, including a resource_link to the full log on failure
CallToolResult BuildToolResult(string toolName, CliSubprocessResult result)
{
var content = new List<ContentBlock>();

if (result.ExitCode == 0)
{
// Success: return stdout content
content.Add(new TextContentBlock { Text = result.Output });
}
else
{
// Failure: prefer stdout (contains structured error envelope from
// OutputFormatter.WriteResult), fall back to stderr error lines.
var errorText = !string.IsNullOrWhiteSpace(result.Output)
? result.Output
: !string.IsNullOrWhiteSpace(result.LastErrors)
? result.LastErrors
: $"Tool '{toolName}' failed with exit code {result.ExitCode}.";
content.Add(new TextContentBlock { Text = errorText });

// Store the full execution log and add a resource_link so the client can fetch details
if (!string.IsNullOrWhiteSpace(result.FullLog))
{
var logUri = toolLogStore.Store(toolName, result.FullLog, result.LastErrors, isError: true);
content.Add(new ResourceLinkBlock
{
Uri = logUri,
Name = $"Full execution log for {toolName}",
Description = "Complete stderr log from the subprocess. Use resources/read to retrieve.",
MimeType = "text/plain"
});
}
}

return new CallToolResult
{
Content = content,
IsError = result.ExitCode != 0
};
}

// MCP resource listing — exposes stored tool execution logs
// MCP resource listing — exposes stored failure-detail resources
ValueTask<ListResourcesResult> ListResourcesAsync(RequestContext<ListResourcesRequestParams> ctx, CancellationToken ct)
{
var entries = toolLogStore.ListAll();
var resources = entries.Select(e => new Resource
{
Uri = e.Uri,
Name = $"Execution log: {e.Entry.ToolName}",
Description = $"Full stderr log from {e.Entry.ToolName} at {e.Entry.Timestamp:u}",
MimeType = "text/plain"
}).ToList();

return ValueTask.FromResult(new ListResourcesResult { Resources = resources });
return ValueTask.FromResult(new ListResourcesResult { Resources = toolResultFactory.BuildResources() });
}

// MCP resource read — returns the full execution log for a given URI
// MCP resource read — returns structured failure details for a given URI
ValueTask<ReadResourceResult> ReadResourceAsync(RequestContext<ReadResourceRequestParams> ctx, CancellationToken ct)
{
var uri = ctx.Params?.Uri ?? throw new McpException("Resource URI is required.");

if (!toolLogStore.TryGet(uri, out var entry) || entry is null)
throw new McpException($"Resource not found: {uri}");

return ValueTask.FromResult(new ReadResourceResult
{
Contents = [new TextResourceContents { Uri = uri, MimeType = "text/plain", Text = entry.FullLog }]
});
return ValueTask.FromResult(toolResultFactory.ReadResource(uri));
}

// Helper method to convert JsonElement to the target property type
Expand Down
60 changes: 50 additions & 10 deletions src/TALXIS.CLI.MCP/ToolLogStore.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using TALXIS.CLI.Core;

namespace TALXIS.CLI.MCP;

/// <summary>
/// In-memory store for tool execution logs, exposed as MCP resources.
/// Logs are keyed by a unique run ID and evicted FIFO when the store exceeds capacity.
/// In-memory store for failed tool diagnostics, exposed as MCP resources.
/// Entries are keyed by a unique run ID and evicted FIFO when the store exceeds capacity.
/// </summary>
internal sealed class ToolLogStore
{
Expand All @@ -11,7 +15,7 @@ internal sealed class ToolLogStore
private readonly Queue<string> _order = [];
private readonly int _maxEntries;

/// <summary>URI scheme prefix for tool log resources.</summary>
/// <summary>URI scheme prefix for failure-detail resources.</summary>
internal const string UriScheme = "txc://logs/";

public ToolLogStore(int maxEntries = 50)
Expand All @@ -21,13 +25,19 @@ public ToolLogStore(int maxEntries = 50)
}

/// <summary>
/// Stores a tool execution log and returns the resource URI.
/// Stores failed tool diagnostics and returns the resource URI.
/// </summary>
public string Store(string toolName, string fullLog, string errorSummary, bool isError)
public string StoreFailure(string toolName, int exitCode, string? primaryText, string? errorSummary, string? fullLog)
{
var runId = Guid.NewGuid().ToString("N")[..12];
var uri = $"{UriScheme}{toolName}/{runId}";
var entry = new LogEntry(toolName, fullLog, errorSummary, isError, DateTimeOffset.UtcNow);
var entry = new LogEntry(
ToolName: toolName,
ExitCode: exitCode,
PrimaryText: Normalize(primaryText),
ErrorSummary: Normalize(errorSummary),
FullLog: Normalize(fullLog),
Timestamp: DateTimeOffset.UtcNow);

lock (_sync)
{
Expand Down Expand Up @@ -66,10 +76,40 @@ public bool TryGet(string uri, out LogEntry? entry)
}
}

private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();

internal sealed record LogEntry(
string ToolName,
string FullLog,
string ErrorSummary,
bool IsError,
DateTimeOffset Timestamp);
int ExitCode,
string? PrimaryText,
string? ErrorSummary,
string? FullLog,
DateTimeOffset Timestamp)
{
public string Kind => "tool-failure-details";

public string Summary => FirstNonEmpty(
PrimaryText,
ErrorSummary,
$"Tool '{ToolName}' failed with exit code {ExitCode}.")!;

[JsonIgnore]
public bool HasFullLog => !string.IsNullOrWhiteSpace(FullLog);

public string ToJson() => JsonSerializer.Serialize(this, TxcOutputJsonOptions.Default);

private static string? FirstNonEmpty(params string?[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}

return null;
}
}
}
10 changes: 10 additions & 0 deletions tests/TALXIS.CLI.IntegrationTests/McpTestClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ public async Task<IList<McpClientTool>> ListToolsAsync()
return await _client.ListToolsAsync();
}

public async Task<IList<McpClientResource>> ListResourcesAsync()
{
return await _client.ListResourcesAsync();
}

public async Task<ReadResourceResult> ReadResourceAsync(string uri)
{
return await _client.ReadResourceAsync(uri);
}

public async ValueTask DisposeAsync()
{
await _client.DisposeAsync();
Expand Down
Loading