Improved the Web Based Harness

This commit is contained in:
milutinke 2026-04-01 12:49:07 +02:00
parent c3c57c058a
commit ee6eb84bd8
19 changed files with 2799 additions and 2202 deletions

View file

@ -0,0 +1,36 @@
using DebugTools.MccMcpWebPlayground.Contracts;
using DebugTools.MccMcpWebPlayground.Harness;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace DebugTools.MccMcpWebPlayground.Api;
public static class MccPlaygroundEndpoints
{
public static IEndpointRouteBuilder MapMccPlaygroundEndpoints(this IEndpointRouteBuilder endpoints)
{
RouteGroupBuilder api = endpoints.MapGroup("/api");
api.MapGet("/health", () => Results.Ok(new { ok = true }));
api.MapGet("/config", (IOptions<MccWebHarnessOptions> options) =>
{
MccWebHarnessOptions harnessOptions = options.Value;
return Results.Ok(new MccConfigResponse(
Model: harnessOptions.ResolveModel(),
OpenRouterBaseUrl: harnessOptions.ResolveOpenRouterBaseUrl(),
McpEndpoint: harnessOptions.ResolveMcpEndpoint(),
HasApiKey: harnessOptions.HasApiKeyConfigured(),
ExposeInventoryWindowAction: harnessOptions.ExposeInventoryWindowAction,
ExposeInternalCommandTool: harnessOptions.ExposeInternalCommandTool));
});
api.MapPost("/chat/stream", (ChatStreamRequest request, IMccAgentRunService runService, HttpContext httpContext, CancellationToken cancellationToken) =>
{
return TypedResults.ServerSentEvents(runService.StreamAsync(request, httpContext, cancellationToken));
})
.WithRequestTimeout("mcc-stream");
return endpoints;
}
}

View file

@ -0,0 +1,94 @@
using System.Text.Json.Serialization;
namespace DebugTools.MccMcpWebPlayground.Contracts;
public sealed class ChatStreamRequest
{
public List<ChatMessage>? Messages { get; set; }
}
public sealed class ChatMessage
{
public string Role { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
}
public sealed record MccConfigResponse(
string? Model,
string OpenRouterBaseUrl,
string McpEndpoint,
bool HasApiKey,
bool ExposeInventoryWindowAction,
bool ExposeInternalCommandTool);
public sealed record MccStreamEnvelope(string RunId, long Sequence, string Kind, object Data);
public sealed record MccRunStartedData(string Model, string McpEndpoint, DateTimeOffset StartedAtUtc);
public sealed record MccGuidanceLoadedData(
string SourceTool,
string CanonicalPromptName,
string GuidanceVersion,
MccCapabilityStatus CapabilityStatus);
public sealed record MccStateSummaryData(
int TurnCount,
int ToolCallCount,
bool SoftFinish,
int DirectAnswerAttempts,
IReadOnlyList<MccVerificationObligationView> OpenVerification,
IReadOnlyList<MccEvidenceView> RecentEvidence,
string? CompactionSummary);
public sealed record MccToolCalledData(string CallId, string Name, string ArgumentsJson, bool Advanced, bool Sensitive);
public sealed record MccToolResultData(
string CallId,
string Name,
bool IsError,
bool Success,
string? ErrorCode,
string Summary,
string RawText,
string EvidenceId);
public sealed record MccVerificationEventData(string ObligationId, string ToolName, string Kind, string Description);
public sealed record MccBudgetData(
int TurnCount,
int MaxTurns,
int ToolCallCount,
int MaxToolCalls,
double ElapsedSeconds,
int MaxWallClockSeconds);
public sealed record MccErrorData(string Code, string Message, string? Detail = null);
public sealed record MccFinalPayload(
string Status,
string Headline,
string AnswerMarkdown,
IReadOnlyList<string> VerifiedFacts,
IReadOnlyList<string> OpenIssues,
IReadOnlyList<string> EvidenceIds,
string? NextAction);
public sealed record MccSubmitFinalArgs(
string Status,
string Headline,
string AnswerMarkdown,
IReadOnlyList<string> VerifiedFacts,
IReadOnlyList<string> OpenIssues,
IReadOnlyList<string> EvidenceIds,
string? NextAction);
public sealed record MccCapabilityStatus(
[property: JsonPropertyName("sessionStatus")] bool SessionStatus,
[property: JsonPropertyName("chatAndCommands")] bool ChatAndCommands,
[property: JsonPropertyName("movement")] bool Movement,
[property: JsonPropertyName("inventory")] bool Inventory,
[property: JsonPropertyName("entityWorld")] bool EntityWorld);
public sealed record MccEvidenceView(string Id, string ToolName, string Summary, bool IsError);
public sealed record MccVerificationObligationView(string Id, string ToolName, string Kind, string Description);

View file

@ -0,0 +1,852 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Net.ServerSentEvents;
using System.Text;
using System.Text.Json;
using DebugTools.MccMcpWebPlayground.Contracts;
using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
using DebugTools.MccMcpWebPlayground.Infrastructure.OpenRouter;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace DebugTools.MccMcpWebPlayground.Harness;
public interface IMccAgentRunService
{
IAsyncEnumerable<SseItem<MccStreamEnvelope>> StreamAsync(ChatStreamRequest request, HttpContext httpContext, CancellationToken cancellationToken);
}
public sealed class MccAgentRunService : IMccAgentRunService
{
private readonly MccMcpSessionFactory sessionFactory;
private readonly MccGuidanceSource guidanceSource;
private readonly MccPromptComposer promptComposer;
private readonly MccContextCompressor contextCompressor;
private readonly MccFinalizer finalizer;
private readonly OpenRouterChatClient openRouterChatClient;
private readonly MccWebHarnessOptions options;
public MccAgentRunService(
MccMcpSessionFactory sessionFactory,
MccGuidanceSource guidanceSource,
MccPromptComposer promptComposer,
MccContextCompressor contextCompressor,
MccFinalizer finalizer,
OpenRouterChatClient openRouterChatClient,
IOptions<MccWebHarnessOptions> options)
{
this.sessionFactory = sessionFactory;
this.guidanceSource = guidanceSource;
this.promptComposer = promptComposer;
this.contextCompressor = contextCompressor;
this.finalizer = finalizer;
this.openRouterChatClient = openRouterChatClient;
this.options = options.Value;
}
public async IAsyncEnumerable<SseItem<MccStreamEnvelope>> StreamAsync(
ChatStreamRequest request,
HttpContext httpContext,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, httpContext.RequestAborted);
CancellationToken linkedToken = linkedCts.Token;
string runId = Guid.NewGuid().ToString("n");
long sequence = 0;
string? model = options.ResolveModel();
if (string.IsNullOrWhiteSpace(model))
{
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("configuration_error", "OPENROUTER_MODEL or MccWebHarness:Model must be configured."));
yield break;
}
if (!options.HasApiKeyConfigured())
{
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("configuration_error", "OPENROUTER_API_KEY is not set."));
yield break;
}
List<object> baseConversationMessages = NormalizeConversation(request.Messages);
string userRequest = ExtractUserRequest(request.Messages);
if (string.IsNullOrWhiteSpace(userRequest))
{
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("invalid_request", "No user message was provided."));
yield break;
}
await using McpClient client = await sessionFactory.CreateAsync(linkedToken);
MccGuidanceBundle guidance = await guidanceSource.LoadAsync(client, linkedToken);
MccRunState runState = new()
{
RunId = runId,
UserRequest = userRequest,
BaseConversationMessages = baseConversationMessages,
ConfiguredModel = model,
Guidance = guidance
};
yield return CreateEvent(runId, ref sequence, "run_started", new MccRunStartedData(model, options.ResolveMcpEndpoint(), runState.StartedAtUtc));
yield return CreateEvent(runId, ref sequence, "guidance_loaded", new MccGuidanceLoadedData(
guidance.SourceToolName,
guidance.CanonicalPromptName,
guidance.GuidanceVersion,
guidance.CapabilityStatus));
IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: linkedToken);
MccToolCatalog catalog = MccToolPolicy.BuildCatalog(tools, options, finalizer.BuildSubmitToolSchema());
while (!linkedToken.IsCancellationRequested)
{
runState.TurnCount++;
contextCompressor.CompactIfNeeded(runState);
yield return CreateEvent(runId, ref sequence, "state_summary", BuildStateSummary(runState, options));
if (runState.IsSoftFinish(options, DateTimeOffset.UtcNow))
{
yield return CreateEvent(runId, ref sequence, "budget", BuildBudgetData(runState));
}
if (runState.IsHardStop(options, DateTimeOffset.UtcNow))
break;
MccModelTurn? turn = null;
Exception? providerException = null;
try
{
turn = await openRouterChatClient.CreateTurnAsync(
promptComposer.Compose(runState),
catalog.ModelVisibleTools,
options,
linkedToken);
}
catch (Exception ex)
{
providerException = ex;
}
if (providerException is not null || turn is null)
{
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("provider_error", "OpenRouter request failed.", providerException?.Message));
yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options));
yield break;
}
runState.RoutedModel = turn.ModelId;
runState.RoutedProvider = turn.RoutedProvider;
if (turn.ToolCalls.Count == 0)
{
runState.DirectAnswerAttempts++;
string content = string.IsNullOrWhiteSpace(turn.AssistantContent) ? "(empty assistant turn)" : turn.AssistantContent.Trim();
runState.ToolConversationMessages.Add(new Dictionary<string, object?>
{
["role"] = "assistant",
["content"] = content
});
if (runState.DirectAnswerAttempts >= 4)
{
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData(
"model_protocol_error",
"The model kept returning plain assistant text instead of using tools or mcc_submit_final.",
content));
yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options));
yield break;
}
runState.ToolConversationMessages.Add(new Dictionary<string, object?>
{
["role"] = "user",
["content"] = "The previous plain assistant text was not accepted by this harness. On your next turn, you must either call the relevant MCC tools or call mcc_submit_final. Do not answer with plain assistant text again."
});
continue;
}
Dictionary<string, object?> assistantMessage = new()
{
["role"] = "assistant",
["content"] = turn.AssistantContent,
["tool_calls"] = turn.ToolCalls.Select(call => new Dictionary<string, object?>
{
["id"] = call.CallId,
["type"] = "function",
["function"] = new Dictionary<string, object?>
{
["name"] = call.Name,
["arguments"] = call.ArgumentsJson
}
}).ToArray()
};
runState.ToolConversationMessages.Add(assistantMessage);
foreach (MccModelToolCall toolCall in turn.ToolCalls)
{
MccToolProfile profile = MccToolPolicy.GetProfile(toolCall.Name);
yield return CreateEvent(runId, ref sequence, "tool_called", new MccToolCalledData(
toolCall.CallId,
toolCall.Name,
toolCall.ArgumentsJson,
profile.Risk == MccToolRisk.EscapeHatch,
profile.Risk == MccToolRisk.Sensitive));
if (toolCall.Name.Equals("mcc_submit_final", StringComparison.OrdinalIgnoreCase))
{
MccFinalizationValidation validation = finalizer.Validate(runState, toolCall.ArgumentsJson);
if (validation.Accepted)
{
yield return CreateEvent(runId, ref sequence, "final", validation.Payload!);
yield break;
}
string localResultText = JsonSerializer.Serialize(new
{
success = false,
errorCode = "invalid_final_submission",
message = validation.ErrorText
});
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, localResultText));
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
toolCall.CallId,
toolCall.Name,
IsError: true,
Success: false,
ErrorCode: "invalid_final_submission",
Summary: validation.ErrorText ?? "Invalid final submission.",
RawText: localResultText,
EvidenceId: string.Empty));
continue;
}
if (MccToolPolicy.RequiresExplicitUserIntent(toolCall.Name) && !MccToolPolicy.HasExplicitUserIntent(runState.UserRequest, toolCall.Name))
{
string localResultText = JsonSerializer.Serialize(new
{
success = false,
errorCode = "explicit_user_intent_required",
message = $"Tool '{toolCall.Name}' requires explicit user intent."
});
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, localResultText));
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
toolCall.CallId,
toolCall.Name,
IsError: true,
Success: false,
ErrorCode: "explicit_user_intent_required",
Summary: $"Tool '{toolCall.Name}' requires explicit user intent.",
RawText: localResultText,
EvidenceId: string.Empty));
continue;
}
if (!catalog.ToolsByName.TryGetValue(toolCall.Name, out MccToolCatalogEntry? entry))
{
string unknownToolText = JsonSerializer.Serialize(new
{
success = false,
errorCode = "unknown_tool",
message = $"Unknown tool '{toolCall.Name}'."
});
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, unknownToolText));
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
toolCall.CallId,
toolCall.Name,
IsError: true,
Success: false,
ErrorCode: "unknown_tool",
Summary: $"Unknown tool '{toolCall.Name}'.",
RawText: unknownToolText,
EvidenceId: string.Empty));
continue;
}
CallToolResult? result = null;
Exception? toolException = null;
try
{
Dictionary<string, object?> arguments = MccJsonArguments.Parse(toolCall.ArgumentsJson);
result = await client.CallToolAsync(toolCall.Name, arguments, cancellationToken: linkedToken);
}
catch (Exception ex)
{
toolException = ex;
}
if (toolException is not null || result is null)
{
string failedText = JsonSerializer.Serialize(new
{
success = false,
errorCode = "tool_call_failed",
message = toolException?.Message
});
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, failedText));
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
toolCall.CallId,
toolCall.Name,
IsError: true,
Success: false,
ErrorCode: "tool_call_failed",
Summary: toolException?.Message ?? "Tool call failed.",
RawText: failedText,
EvidenceId: string.Empty));
continue;
}
runState.ToolCallCount++;
MccNormalizedToolResult normalized = MccMcpJson.Normalize(result);
MccEvidenceRecord evidence = CreateEvidence(runState, toolCall.Name, normalized);
runState.Evidence.Add(evidence);
runState.ToolExecutions.Add(new MccToolExecutionRecord
{
CallId = toolCall.CallId,
ToolName = toolCall.Name,
ArgumentsJson = toolCall.ArgumentsJson,
Evidence = evidence
});
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, normalized.Text));
foreach (MccVerificationObligation obligation in CreateObligations(runState, evidence, toolCall.ArgumentsJson))
{
runState.VerificationObligations.Add(obligation);
yield return CreateEvent(runId, ref sequence, "verification_required", new MccVerificationEventData(
obligation.Id,
obligation.ToolName,
obligation.Kind,
obligation.Description));
if (obligation.Cleared)
{
yield return CreateEvent(runId, ref sequence, "verification_cleared", new MccVerificationEventData(
obligation.Id,
obligation.ToolName,
obligation.Kind,
obligation.Description));
}
}
foreach (MccVerificationObligation cleared in TryClearObligationsFromEvidence(runState, evidence))
{
yield return CreateEvent(runId, ref sequence, "verification_cleared", new MccVerificationEventData(
cleared.Id,
cleared.ToolName,
cleared.Kind,
cleared.Description));
}
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
toolCall.CallId,
toolCall.Name,
evidence.IsError,
evidence.Success,
evidence.ErrorCode,
evidence.Summary,
evidence.RawText,
evidence.Id));
}
}
yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options));
}
private static List<object> NormalizeConversation(List<ChatMessage>? incoming)
{
List<object> messages = [];
if (incoming is null)
return messages;
foreach (ChatMessage message in incoming)
{
if (string.IsNullOrWhiteSpace(message.Role) || string.IsNullOrWhiteSpace(message.Content))
continue;
string role = message.Role.Trim().ToLowerInvariant();
if (role is not ("user" or "assistant" or "system"))
continue;
messages.Add(new Dictionary<string, object?>
{
["role"] = role,
["content"] = message.Content.Trim()
});
}
return messages;
}
private static string ExtractUserRequest(List<ChatMessage>? incoming)
{
return incoming?
.LastOrDefault(message => string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(message.Content))
?.Content
?.Trim()
?? string.Empty;
}
private static Dictionary<string, object?> BuildToolMessage(string callId, string content)
{
return new Dictionary<string, object?>
{
["role"] = "tool",
["tool_call_id"] = callId,
["content"] = content
};
}
private static MccStateSummaryData BuildStateSummary(MccRunState runState, MccWebHarnessOptions options)
{
return new MccStateSummaryData(
TurnCount: runState.TurnCount,
ToolCallCount: runState.ToolCallCount,
SoftFinish: runState.IsSoftFinish(options, DateTimeOffset.UtcNow),
DirectAnswerAttempts: runState.DirectAnswerAttempts,
OpenVerification: runState.OpenObligations
.Select(obligation => new MccVerificationObligationView(obligation.Id, obligation.ToolName, obligation.Kind, obligation.Description))
.ToArray(),
RecentEvidence: runState.Evidence
.TakeLast(6)
.Select(evidence => new MccEvidenceView(evidence.Id, evidence.ToolName, evidence.Summary, evidence.IsError))
.ToArray(),
CompactionSummary: runState.CompactionSummary);
}
private MccBudgetData BuildBudgetData(MccRunState runState)
{
return new MccBudgetData(
TurnCount: runState.TurnCount,
MaxTurns: options.MaxTurns,
ToolCallCount: runState.ToolCallCount,
MaxToolCalls: options.MaxToolCalls,
ElapsedSeconds: (DateTimeOffset.UtcNow - runState.StartedAtUtc).TotalSeconds,
MaxWallClockSeconds: options.MaxWallClockSeconds);
}
private static MccEvidenceRecord CreateEvidence(MccRunState runState, string toolName, MccNormalizedToolResult result)
{
string summary = SummarizeEvidence(toolName, result);
return new MccEvidenceRecord
{
Id = runState.NextEvidenceId(),
ToolName = toolName,
Summary = summary,
RawText = result.Text,
IsError = result.IsError,
Success = result.Success,
ErrorCode = result.ErrorCode,
Root = result.Root,
Data = result.Data
};
}
private static string SummarizeEvidence(string toolName, MccNormalizedToolResult result)
{
if (result.Data is JsonElement data)
{
if ((toolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase) || toolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase))
&& TryReadBool(data, "arrived", out bool arrived))
{
return arrived
? $"movement verified; arrived={arrived}"
: $"movement not yet verified; arrived={arrived}";
}
if (toolName.Equals("mcc_dig_block", StringComparison.OrdinalIgnoreCase))
{
bool destroyed = TryReadBool(data, "destroyed", out bool destroyedValue) && destroyedValue;
bool changed = TryReadBool(data, "changed", out bool changedValue) && changedValue;
return $"dig result changed={changed} destroyed={destroyed}";
}
if (toolName.Equals("mcc_items_pickup", StringComparison.OrdinalIgnoreCase))
{
int successful = TryReadInt(data, "successfulPickups", out int successfulValue) ? successfulValue : 0;
int collected = TryReadInt(data, "collectedCount", out int collectedValue) ? collectedValue : 0;
return $"pickup result successfulPickups={successful} collectedCount={collected}";
}
if (toolName.Equals("mcc_container_open_at", StringComparison.OrdinalIgnoreCase)
&& TryReadBool(data, "opened", out bool opened))
{
return $"container open result opened={opened}";
}
if (toolName is "mcc_container_deposit_item" or "mcc_container_withdraw_item" or "mcc_inventory_drop_item")
{
int moved = TryReadInt(data, "movedCount", out int movedValue)
? movedValue
: TryReadInt(data, "droppedCount", out int droppedValue) ? droppedValue : 0;
return $"{toolName} movedCount={moved}";
}
}
string prefix = result.IsError ? "error" : "ok";
return $"{prefix}: {Truncate(result.Text.Replace('\n', ' '), 180)}";
}
private List<MccVerificationObligation> CreateObligations(MccRunState runState, MccEvidenceRecord evidence, string argumentsJson)
{
List<MccVerificationObligation> obligations = [];
JsonElement metadata = ParseArgumentsToJson(argumentsJson);
if (evidence.ToolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase))
{
MccVerificationObligation obligation = new()
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "movement",
Description = "Verify final player location for the requested move target.",
SourceEvidenceId = evidence.Id,
Metadata = BuildMoveMetadata(evidence, metadata),
Cleared = IsMovementVerified(evidence)
};
obligations.Add(obligation);
return obligations;
}
if (evidence.ToolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase))
{
MccVerificationObligation obligation = new()
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "movement",
Description = "Verify final proximity to the requested player target.",
SourceEvidenceId = evidence.Id,
Metadata = BuildMoveToPlayerMetadata(evidence, metadata),
Cleared = IsMovementVerified(evidence)
};
obligations.Add(obligation);
return obligations;
}
if (evidence.ToolName.Equals("mcc_container_open_at", StringComparison.OrdinalIgnoreCase))
{
obligations.Add(new MccVerificationObligation
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "container",
Description = "Verify that the target container is open and active.",
SourceEvidenceId = evidence.Id,
Metadata = null,
Cleared = IsContainerOpenVerified(evidence)
});
return obligations;
}
if (evidence.ToolName is "mcc_container_deposit_item" or "mcc_container_withdraw_item" or "mcc_inventory_drop_item")
{
obligations.Add(new MccVerificationObligation
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "inventory",
Description = "Verify the requested inventory delta.",
SourceEvidenceId = evidence.Id,
Metadata = evidence.Data,
Cleared = IsInventoryVerified(evidence)
});
return obligations;
}
if (evidence.ToolName.Equals("mcc_items_pickup", StringComparison.OrdinalIgnoreCase))
{
obligations.Add(new MccVerificationObligation
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "pickup",
Description = "Verify that the requested dropped items were picked up.",
SourceEvidenceId = evidence.Id,
Metadata = evidence.Data,
Cleared = IsPickupVerified(evidence)
});
return obligations;
}
if (evidence.ToolName.Equals("mcc_dig_block", StringComparison.OrdinalIgnoreCase))
{
obligations.Add(new MccVerificationObligation
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "block_change",
Description = "Verify that the target block changed state after digging.",
SourceEvidenceId = evidence.Id,
Metadata = evidence.Data,
Cleared = IsDigVerified(evidence)
});
}
return obligations;
}
private List<MccVerificationObligation> TryClearObligationsFromEvidence(MccRunState runState, MccEvidenceRecord evidence)
{
List<MccVerificationObligation> cleared = [];
foreach (MccVerificationObligation obligation in runState.OpenObligations)
{
if (obligation.Cleared)
continue;
if (obligation.Kind == "movement" && TryClearMovementObligation(obligation, evidence))
{
obligation.Cleared = true;
obligation.ClearedByEvidenceId = evidence.Id;
cleared.Add(obligation);
continue;
}
if (obligation.Kind == "block_change" && TryClearDigObligation(obligation, evidence))
{
obligation.Cleared = true;
obligation.ClearedByEvidenceId = evidence.Id;
cleared.Add(obligation);
}
}
return cleared;
}
private static bool TryClearMovementObligation(MccVerificationObligation obligation, MccEvidenceRecord evidence)
{
if (evidence.ToolName.Equals("mcc_player_state", StringComparison.OrdinalIgnoreCase)
&& evidence.Data is JsonElement data
&& data.TryGetProperty("location", out JsonElement location)
&& obligation.Metadata is JsonElement metadata)
{
if (obligation.ToolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase)
&& metadata.TryGetProperty("x", out JsonElement targetX)
&& metadata.TryGetProperty("y", out JsonElement targetY)
&& metadata.TryGetProperty("z", out JsonElement targetZ))
{
double tolerance = metadata.TryGetProperty("tolerance", out JsonElement toleranceElement) && toleranceElement.TryGetDouble(out double tol) ? tol : 1.5;
return TryReadDouble(location, "x", out double x)
&& TryReadDouble(location, "y", out double y)
&& TryReadDouble(location, "z", out double z)
&& Distance(x, y, z, targetX.GetDouble(), targetY.GetDouble(), targetZ.GetDouble()) <= tolerance;
}
}
if (evidence.ToolName.Equals("mcc_player_locate", StringComparison.OrdinalIgnoreCase)
&& obligation.ToolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase)
&& evidence.Data is JsonElement playerData
&& obligation.Metadata is JsonElement playerMetadata)
{
string? expectedName = playerMetadata.TryGetProperty("playerName", out JsonElement nameElement) ? nameElement.GetString() : null;
string? matchedName = playerData.TryGetProperty("matchedName", out JsonElement matchedNameElement) ? matchedNameElement.GetString() : null;
if (!string.IsNullOrWhiteSpace(expectedName) && !string.Equals(expectedName, matchedName, StringComparison.OrdinalIgnoreCase))
return false;
if (TryReadDouble(playerData, "distance", out double distance))
{
double tolerance = playerMetadata.TryGetProperty("tolerance", out JsonElement toleranceElement) && toleranceElement.TryGetDouble(out double tol) ? tol : 2.0;
return distance <= tolerance;
}
}
return false;
}
private static bool TryClearDigObligation(MccVerificationObligation obligation, MccEvidenceRecord evidence)
{
if (!evidence.ToolName.Equals("mcc_world_block_at", StringComparison.OrdinalIgnoreCase)
|| evidence.Data is not JsonElement data
|| obligation.Metadata is not JsonElement metadata)
{
return false;
}
if (!metadata.TryGetProperty("target", out JsonElement target)
|| !TryReadDouble(target, "x", out double x)
|| !TryReadDouble(target, "y", out double y)
|| !TryReadDouble(target, "z", out double z))
{
return false;
}
return TryReadInt(data, "x", out int blockX)
&& TryReadInt(data, "y", out int blockY)
&& TryReadInt(data, "z", out int blockZ)
&& Math.Abs(blockX - x) < 0.5
&& Math.Abs(blockY - y) < 0.5
&& Math.Abs(blockZ - z) < 0.5
&& data.TryGetProperty("block", out JsonElement block)
&& block.TryGetProperty("material", out JsonElement material)
&& !string.Equals(material.GetString(), "Air", StringComparison.OrdinalIgnoreCase);
}
private static bool IsMovementVerified(MccEvidenceRecord evidence)
{
if (evidence.Data is not JsonElement data)
return false;
if (TryReadBool(data, "arrived", out bool arrived) && arrived)
return true;
if (TryReadDouble(data, "finalDistance", out double finalDistance))
{
double tolerance = TryReadDouble(data, "tolerance", out double tol) ? tol : 1.5;
return finalDistance <= tolerance;
}
return false;
}
private static bool IsContainerOpenVerified(MccEvidenceRecord evidence)
{
return evidence.Data is JsonElement data
&& TryReadBool(data, "opened", out bool opened)
&& opened;
}
private static bool IsInventoryVerified(MccEvidenceRecord evidence)
{
if (evidence.Data is not JsonElement data)
return false;
if (TryReadInt(data, "requestedCount", out int requestedCount)
&& TryReadInt(data, "movedCount", out int movedCount))
{
return movedCount == requestedCount;
}
if (TryReadInt(data, "requestedCount", out requestedCount)
&& TryReadInt(data, "droppedCount", out int droppedCount))
{
return droppedCount == requestedCount;
}
return evidence.Success;
}
private static bool IsPickupVerified(MccEvidenceRecord evidence)
{
if (evidence.Data is not JsonElement data)
return false;
return (TryReadInt(data, "successfulPickups", out int successfulPickups) && successfulPickups > 0)
|| (TryReadInt(data, "collectedCount", out int collectedCount) && collectedCount > 0);
}
private static bool IsDigVerified(MccEvidenceRecord evidence)
{
if (evidence.Data is not JsonElement data)
return false;
return (TryReadBool(data, "destroyed", out bool destroyed) && destroyed)
|| (TryReadBool(data, "changed", out bool changed) && changed);
}
private static JsonElement? BuildMoveMetadata(MccEvidenceRecord evidence, JsonElement arguments)
{
if (evidence.Data is not JsonElement data)
return null;
double x = TryReadDoubleFromArguments(arguments, "x", out double targetX)
? targetX
: data.TryGetProperty("target", out JsonElement target) && TryReadDouble(target, "x", out double fromDataX) ? fromDataX : 0;
double y = TryReadDoubleFromArguments(arguments, "y", out double targetY)
? targetY
: data.TryGetProperty("target", out target) && TryReadDouble(target, "y", out double fromDataY) ? fromDataY : 0;
double z = TryReadDoubleFromArguments(arguments, "z", out double targetZ)
? targetZ
: data.TryGetProperty("target", out target) && TryReadDouble(target, "z", out double fromDataZ) ? fromDataZ : 0;
double tolerance = TryReadDouble(data, "tolerance", out double tol) ? tol : 1.5;
return JsonSerializer.SerializeToElement(new
{
x,
y,
z,
tolerance
});
}
private static JsonElement? BuildMoveToPlayerMetadata(MccEvidenceRecord evidence, JsonElement arguments)
{
string? playerName = arguments.TryGetProperty("playerName", out JsonElement property) ? property.GetString() : null;
double tolerance = evidence.Data is JsonElement data && TryReadDouble(data, "tolerance", out double tol) ? tol : 2.0;
return JsonSerializer.SerializeToElement(new
{
playerName,
tolerance
});
}
private static JsonElement ParseArgumentsToJson(string argumentsJson)
{
try
{
using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
return document.RootElement.Clone();
}
catch
{
using JsonDocument document = JsonDocument.Parse("{}");
return document.RootElement.Clone();
}
}
private static bool TryReadBool(JsonElement element, string propertyName, out bool value)
{
value = false;
return element.TryGetProperty(propertyName, out JsonElement property)
&& property.ValueKind is JsonValueKind.True or JsonValueKind.False
&& ((value = property.GetBoolean()) || !value || true);
}
private static bool TryReadInt(JsonElement element, string propertyName, out int value)
{
value = 0;
return element.TryGetProperty(propertyName, out JsonElement property) && property.TryGetInt32(out value);
}
private static bool TryReadDouble(JsonElement element, string propertyName, out double value)
{
value = 0;
return element.TryGetProperty(propertyName, out JsonElement property) && property.TryGetDouble(out value);
}
private static bool TryReadDoubleFromArguments(JsonElement element, string propertyName, out double value)
{
value = 0;
if (!element.TryGetProperty(propertyName, out JsonElement property))
return false;
return property.ValueKind == JsonValueKind.Number
? property.TryGetDouble(out value)
: property.ValueKind == JsonValueKind.String && double.TryParse(property.GetString(), out value);
}
private static double Distance(double x1, double y1, double z1, double x2, double y2, double z2)
{
double dx = x1 - x2;
double dy = y1 - y2;
double dz = z1 - z2;
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
}
private static string Truncate(string text, int maxLength)
{
return string.IsNullOrEmpty(text) || text.Length <= maxLength ? text : text[..maxLength] + "...";
}
private static SseItem<MccStreamEnvelope> CreateEvent<T>(string runId, ref long sequence, string kind, T data)
{
sequence++;
return new SseItem<MccStreamEnvelope>(
new MccStreamEnvelope(runId, sequence, kind, data!),
kind)
{
EventId = sequence.ToString(CultureInfo.InvariantCulture)
};
}
}

View file

@ -0,0 +1,21 @@
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccContextCompressor
{
public void CompactIfNeeded(MccRunState runState)
{
if (runState.Evidence.Count <= 6)
return;
IReadOnlyList<MccEvidenceRecord> olderEvidence = runState.Evidence
.Take(Math.Max(0, runState.Evidence.Count - 6))
.ToArray();
if (olderEvidence.Count == 0)
return;
runState.CompactionSummary = string.Join('\n', olderEvidence
.TakeLast(8)
.Select(record => $"- {record.Id} {record.ToolName}: {record.Summary}"));
}
}

View file

@ -0,0 +1,221 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using DebugTools.MccMcpWebPlayground.Contracts;
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccFinalizer
{
private static readonly string[] AllowedStatuses = ["completed", "partial", "blocked", "clarification_needed", "failed"];
public object BuildSubmitToolSchema()
{
return new Dictionary<string, object?>
{
["type"] = "function",
["function"] = new Dictionary<string, object?>
{
["name"] = "mcc_submit_final",
["description"] = "Submit the final result for this MCC run. Use completed only when no required verification obligations remain open.",
["parameters"] = new JsonObject
{
["type"] = "object",
["additionalProperties"] = false,
["properties"] = new JsonObject
{
["status"] = new JsonObject
{
["type"] = "string",
["enum"] = new JsonArray(AllowedStatuses.Select(status => JsonValue.Create(status)).ToArray())
},
["headline"] = new JsonObject { ["type"] = "string" },
["answerMarkdown"] = new JsonObject { ["type"] = "string" },
["verifiedFacts"] = new JsonObject
{
["type"] = "array",
["items"] = new JsonObject { ["type"] = "string" }
},
["openIssues"] = new JsonObject
{
["type"] = "array",
["items"] = new JsonObject { ["type"] = "string" }
},
["evidenceIds"] = new JsonObject
{
["type"] = "array",
["items"] = new JsonObject { ["type"] = "string" }
},
["nextAction"] = new JsonObject
{
["type"] = new JsonArray("string", "null")
}
},
["required"] = new JsonArray("status", "headline", "answerMarkdown", "verifiedFacts", "openIssues", "evidenceIds", "nextAction")
}
}
};
}
public MccFinalizationValidation Validate(MccRunState runState, string argumentsJson)
{
try
{
using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
JsonElement root = document.RootElement;
MccSubmitFinalArgs submission = new(
Status: ReadRequiredString(root, "status"),
Headline: ReadRequiredString(root, "headline"),
AnswerMarkdown: ReadRequiredString(root, "answerMarkdown"),
VerifiedFacts: ReadStringArray(root, "verifiedFacts"),
OpenIssues: ReadStringArray(root, "openIssues"),
EvidenceIds: ReadStringArray(root, "evidenceIds"),
NextAction: ReadNullableString(root, "nextAction"));
string normalizedStatus = submission.Status.Trim().ToLowerInvariant();
if (!AllowedStatuses.Contains(normalizedStatus, StringComparer.Ordinal))
return MccFinalizationValidation.Reject("Invalid final status.");
if (string.IsNullOrWhiteSpace(submission.Headline) || string.IsNullOrWhiteSpace(submission.AnswerMarkdown))
return MccFinalizationValidation.Reject("headline and answerMarkdown are required.");
Dictionary<string, MccEvidenceRecord> evidenceById = runState.Evidence.ToDictionary(record => record.Id, StringComparer.OrdinalIgnoreCase);
Dictionary<string, string> evidenceAliasByCallId = new(StringComparer.OrdinalIgnoreCase);
foreach (MccToolExecutionRecord execution in runState.ToolExecutions)
{
evidenceAliasByCallId[execution.CallId] = execution.Evidence.Id;
int suffixSeparator = execution.CallId.LastIndexOf('_');
if (suffixSeparator >= 0 && suffixSeparator < execution.CallId.Length - 1)
evidenceAliasByCallId[execution.CallId[(suffixSeparator + 1)..]] = execution.Evidence.Id;
}
List<string> normalizedEvidenceIds = [];
foreach (string evidenceId in submission.EvidenceIds)
{
string normalizedEvidenceId = evidenceAliasByCallId.TryGetValue(evidenceId, out string? mappedEvidenceId)
? mappedEvidenceId
: evidenceId;
if (!evidenceById.ContainsKey(normalizedEvidenceId))
return MccFinalizationValidation.Reject($"Unknown evidence id '{evidenceId}'.");
if (!normalizedEvidenceIds.Contains(normalizedEvidenceId, StringComparer.OrdinalIgnoreCase))
normalizedEvidenceIds.Add(normalizedEvidenceId);
}
if (normalizedStatus == "completed" && runState.OpenObligations.Count > 0)
return MccFinalizationValidation.Reject("completed is invalid while verification obligations remain open.");
if (!AreVerifiedFactsGrounded(submission.VerifiedFacts, normalizedEvidenceIds, evidenceById))
return MccFinalizationValidation.Reject("verifiedFacts must be grounded in the referenced evidence.");
return MccFinalizationValidation.Accept(new MccFinalPayload(
normalizedStatus,
submission.Headline.Trim(),
submission.AnswerMarkdown.Trim(),
submission.VerifiedFacts,
submission.OpenIssues,
normalizedEvidenceIds,
string.IsNullOrWhiteSpace(submission.NextAction) ? null : submission.NextAction.Trim()));
}
catch (Exception ex)
{
return MccFinalizationValidation.Reject($"Invalid mcc_submit_final payload: {ex.Message}");
}
}
public MccFinalPayload BuildHardStopResult(MccRunState runState, MccWebHarnessOptions options)
{
IReadOnlyList<string> openIssues = runState.OpenObligations.Count > 0
? runState.OpenObligations.Select(obligation => obligation.Description).ToArray()
: ["The harness reached its execution budget before the run was explicitly finalized."];
IReadOnlyList<string> evidenceIds = runState.Evidence.TakeLast(4).Select(record => record.Id).ToArray();
IReadOnlyList<string> verifiedFacts = runState.Evidence
.TakeLast(4)
.Where(record => record.Success)
.Select(record => record.Summary)
.ToArray();
return new MccFinalPayload(
Status: runState.OpenObligations.Count > 0 ? "partial" : "blocked",
Headline: "Run stopped before explicit completion",
AnswerMarkdown: "I could not finish the request within the current harness budget. I am returning the strongest verified state captured so far.",
VerifiedFacts: verifiedFacts,
OpenIssues: openIssues,
EvidenceIds: evidenceIds,
NextAction: "Retry with a fresh run if you want me to continue from the latest verified state.");
}
private static bool AreVerifiedFactsGrounded(
IReadOnlyList<string> verifiedFacts,
IReadOnlyList<string> evidenceIds,
IReadOnlyDictionary<string, MccEvidenceRecord> evidenceById)
{
if (verifiedFacts.Count == 0)
return true;
if (evidenceIds.Count == 0)
return false;
string evidenceCorpus = string.Join(' ', evidenceIds
.Where(evidenceById.ContainsKey)
.Select(id => evidenceById[id].Summary))
.ToLowerInvariant();
foreach (string fact in verifiedFacts)
{
HashSet<string> factTokens = fact.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(token => token.Trim().Trim(',', '.', ':', ';', '!', '?', '"', '\''))
.Where(token => token.Length >= 4)
.Select(token => token.ToLowerInvariant())
.ToHashSet(StringComparer.Ordinal);
if (factTokens.Count == 0)
continue;
int matches = factTokens.Count(token => evidenceCorpus.Contains(token, StringComparison.Ordinal));
if (matches < Math.Min(2, factTokens.Count))
return false;
}
return true;
}
private static string ReadRequiredString(JsonElement root, string propertyName)
{
string? value = ReadNullableString(root, propertyName);
if (string.IsNullOrWhiteSpace(value))
throw new InvalidOperationException($"{propertyName} is required.");
return value.Trim();
}
private static string? ReadNullableString(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out JsonElement property))
return null;
return property.ValueKind == JsonValueKind.Null ? null : property.GetString();
}
private static string[] ReadStringArray(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out JsonElement property) || property.ValueKind != JsonValueKind.Array)
return [];
return property.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.String)
.Select(item => item.GetString())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Cast<string>()
.ToArray();
}
}
public sealed record MccFinalizationValidation(bool Accepted, string? ErrorText, MccFinalPayload? Payload)
{
public static MccFinalizationValidation Accept(MccFinalPayload payload) => new(true, null, payload);
public static MccFinalizationValidation Reject(string errorText) => new(false, errorText, null);
}

View file

@ -0,0 +1,63 @@
using System.Text.Json;
using DebugTools.MccMcpWebPlayground.Contracts;
using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccGuidanceSource
{
public const string SourceToolName = "mcc_agent_guidance";
public const string CanonicalPromptName = "mcc_operator_guide";
public async Task<MccGuidanceBundle> LoadAsync(McpClient client, CancellationToken cancellationToken)
{
CallToolResult result = await client.CallToolAsync(SourceToolName, new Dictionary<string, object?>(), cancellationToken: cancellationToken);
MccNormalizedToolResult normalized = MccMcpJson.Normalize(result);
JsonElement data = normalized.Data ?? throw new InvalidOperationException("mcc_agent_guidance did not return data.");
string[] bestPractices = ReadStringArray(data, "bestPractices");
string[] exampleTitles = data.TryGetProperty("exampleScenarios", out JsonElement examples)
&& examples.ValueKind == JsonValueKind.Array
? examples.EnumerateArray()
.Select(example => example.TryGetProperty("title", out JsonElement title) ? title.GetString() : null)
.Where(title => !string.IsNullOrWhiteSpace(title))
.Cast<string>()
.ToArray()
: [];
MccCapabilityStatus capabilityStatus = data.TryGetProperty("capabilityStatus", out JsonElement capabilityJson)
? JsonSerializer.Deserialize<MccCapabilityStatus>(capabilityJson.GetRawText()) ?? new MccCapabilityStatus(false, false, false, false, false)
: new MccCapabilityStatus(false, false, false, false, false);
return new MccGuidanceBundle(
SourceToolName,
CanonicalPromptName,
SkillName: ReadString(data, "skillName") ?? "mcc-mcp-operator",
GuidanceVersion: ReadString(data, "guidanceVersion") ?? "unknown",
SystemPrompt: ReadString(data, "systemPrompt") ?? throw new InvalidOperationException("mcc_agent_guidance did not return systemPrompt."),
BestPractices: bestPractices,
ExampleScenarioTitles: exampleTitles,
CapabilityStatus: capabilityStatus);
}
private static string? ReadString(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static string[] ReadStringArray(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.Array
? property.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.String)
.Select(item => item.GetString())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Cast<string>()
.ToArray()
: [];
}
}

View file

@ -0,0 +1,86 @@
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccPromptComposer
{
private const string HarnessContract = """
You are operating Minecraft Console Client through MCC MCP tools.
Rules:
- Use tool results and the run-state summary as the source of truth.
- Execute tools sequentially.
- End the run only with mcc_submit_final.
- status=completed is valid only when no required verification obligations remain open.
- If the task is blocked or partial, say exactly what is verified and what remains unverified.
- Do not repeat the same failing stateful action with the same arguments.
- mcc_quit_client requires explicit user intent.
- Prefer structured high-level tools. Avoid escape hatches unless they are explicitly exposed and necessary.
""";
public List<object> Compose(MccRunState runState)
{
List<object> messages =
[
BuildSystemMessage(HarnessContract),
BuildSystemMessage(runState.Guidance.SystemPrompt),
BuildSystemMessage(BuildStateSummary(runState)),
.. runState.BaseConversationMessages
];
if (!string.IsNullOrWhiteSpace(runState.CompactionSummary))
{
messages.Add(BuildSystemMessage($"""
Older verified evidence summary
{runState.CompactionSummary}
"""));
}
foreach (object message in runState.ToolConversationMessages.TakeLast(12))
messages.Add(message);
return messages;
}
private static Dictionary<string, object?> BuildSystemMessage(string text)
{
return new Dictionary<string, object?>
{
["role"] = "system",
["content"] = text
};
}
private static string BuildStateSummary(MccRunState runState)
{
string evidence = runState.Evidence.Count == 0
? "- none yet"
: string.Join('\n', runState.Evidence.TakeLast(6).Select(record =>
$"- {record.Id} {record.ToolName}: {record.Summary}"));
string obligations = runState.OpenObligations.Count == 0
? "- none"
: string.Join('\n', runState.OpenObligations.Select(obligation =>
$"- {obligation.Id} {obligation.ToolName}/{obligation.Kind}: {obligation.Description}"));
string bestPractices = runState.Guidance.BestPractices.Length == 0
? "- use verified MCC state before claiming success"
: string.Join('\n', runState.Guidance.BestPractices.Take(4).Select(item => $"- {item}"));
return $"""
Current run state
- turnCount: {runState.TurnCount}
- toolCallCount: {runState.ToolCallCount}
- directAnswerAttempts: {runState.DirectAnswerAttempts}
- routedModel: {runState.RoutedModel ?? runState.ConfiguredModel}
- routedProvider: {runState.RoutedProvider ?? "unknown"}
Outstanding verification
{obligations}
Recent evidence
{evidence}
Guidance highlights
{bestPractices}
""";
}
}

View file

@ -0,0 +1,103 @@
using System.Text.Json;
using DebugTools.MccMcpWebPlayground.Contracts;
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccRunState
{
private int evidenceCounter;
private int obligationCounter;
public required string RunId { get; init; }
public required string UserRequest { get; init; }
public required List<object> BaseConversationMessages { get; init; }
public required string ConfiguredModel { get; init; }
public required MccGuidanceBundle Guidance { get; init; }
public DateTimeOffset StartedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public List<object> ToolConversationMessages { get; } = [];
public List<MccEvidenceRecord> Evidence { get; } = [];
public List<MccToolExecutionRecord> ToolExecutions { get; } = [];
public List<MccVerificationObligation> VerificationObligations { get; } = [];
public string? CompactionSummary { get; set; }
public string? RoutedModel { get; set; }
public string? RoutedProvider { get; set; }
public int TurnCount { get; set; }
public int ToolCallCount { get; set; }
public int DirectAnswerAttempts { get; set; }
public string NextEvidenceId() => $"e{++evidenceCounter:0000}";
public string NextObligationId() => $"v{++obligationCounter:0000}";
public bool IsSoftFinish(MccWebHarnessOptions options, DateTimeOffset nowUtc)
{
TimeSpan elapsed = nowUtc - StartedAtUtc;
return (options.MaxTurns - TurnCount) <= options.SoftFinishRemainingTurns
|| (options.MaxToolCalls - ToolCallCount) <= options.SoftFinishRemainingToolCalls
|| (options.MaxWallClockSeconds - (int)elapsed.TotalSeconds) <= options.SoftFinishRemainingSeconds;
}
public bool IsHardStop(MccWebHarnessOptions options, DateTimeOffset nowUtc)
{
TimeSpan elapsed = nowUtc - StartedAtUtc;
return TurnCount >= options.MaxTurns
|| ToolCallCount >= options.MaxToolCalls
|| elapsed.TotalSeconds >= options.MaxWallClockSeconds;
}
public IReadOnlyList<MccVerificationObligation> OpenObligations =>
VerificationObligations.Where(obligation => !obligation.Cleared).ToArray();
}
public sealed record MccGuidanceBundle(
string SourceToolName,
string CanonicalPromptName,
string SkillName,
string GuidanceVersion,
string SystemPrompt,
string[] BestPractices,
string[] ExampleScenarioTitles,
MccCapabilityStatus CapabilityStatus);
public sealed class MccEvidenceRecord
{
public required string Id { get; init; }
public required string ToolName { get; init; }
public required string Summary { get; init; }
public required string RawText { get; init; }
public required bool IsError { get; init; }
public required bool Success { get; init; }
public string? ErrorCode { get; init; }
public JsonElement? Root { get; init; }
public JsonElement? Data { get; init; }
}
public sealed class MccToolExecutionRecord
{
public required string CallId { get; init; }
public required string ToolName { get; init; }
public required string ArgumentsJson { get; init; }
public required MccEvidenceRecord Evidence { get; init; }
}
public sealed class MccVerificationObligation
{
public required string Id { get; init; }
public required string ToolName { get; init; }
public required string Kind { get; init; }
public required string Description { get; init; }
public required string SourceEvidenceId { get; init; }
public JsonElement? Metadata { get; init; }
public bool Cleared { get; set; }
public string? ClearedByEvidenceId { get; set; }
}
public sealed record MccNormalizedToolResult(
string Text,
bool IsError,
bool Success,
string? ErrorCode,
string? Message,
JsonElement? Root,
JsonElement? Data);

View file

@ -0,0 +1,133 @@
using System.Collections.Frozen;
using System.Text.Json.Nodes;
using ModelContextProtocol.Client;
namespace DebugTools.MccMcpWebPlayground.Harness;
public enum MccToolRisk
{
ReadOnly,
Stateful,
Sensitive,
EscapeHatch
}
public sealed record MccToolProfile(
string Name,
MccToolRisk Risk,
bool VisibleByDefault,
bool RequiresExplicitUserIntent);
public sealed record MccToolCatalogEntry(McpClientTool Tool, MccToolProfile Profile);
public sealed class MccToolCatalog
{
public required Dictionary<string, MccToolCatalogEntry> ToolsByName { get; init; }
public required IReadOnlyList<object> ModelVisibleTools { get; init; }
}
public static class MccToolPolicy
{
private static readonly FrozenDictionary<string, MccToolProfile> Profiles =
new Dictionary<string, MccToolProfile>(StringComparer.OrdinalIgnoreCase)
{
["mcc_agent_guidance"] = new("mcc_agent_guidance", MccToolRisk.ReadOnly, false, false),
["mcc_inventory_window_action"] = new("mcc_inventory_window_action", MccToolRisk.EscapeHatch, false, false),
["mcc_run_internal_command"] = new("mcc_run_internal_command", MccToolRisk.EscapeHatch, false, false),
["mcc_quit_client"] = new("mcc_quit_client", MccToolRisk.Sensitive, true, true)
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
public static MccToolProfile GetProfile(string toolName)
{
return Profiles.TryGetValue(toolName, out MccToolProfile? profile)
? profile
: new MccToolProfile(toolName, MccToolRisk.Stateful, true, false);
}
public static MccToolCatalog BuildCatalog(IList<McpClientTool> tools, MccWebHarnessOptions options, object submitFinalTool)
{
Dictionary<string, MccToolCatalogEntry> toolsByName = tools.ToDictionary(
tool => tool.Name,
tool => new MccToolCatalogEntry(tool, GetProfile(tool.Name)),
StringComparer.OrdinalIgnoreCase);
List<object> visibleTools = [];
foreach (MccToolCatalogEntry entry in toolsByName.Values.OrderBy(entry => entry.Tool.Name, StringComparer.OrdinalIgnoreCase))
{
if (!IsVisible(entry.Profile, options))
continue;
visibleTools.Add(ToOpenRouterTool(entry.Tool, entry.Profile));
}
visibleTools.Add(submitFinalTool);
return new MccToolCatalog
{
ToolsByName = toolsByName,
ModelVisibleTools = visibleTools
};
}
public static bool RequiresExplicitUserIntent(string toolName)
{
return GetProfile(toolName).RequiresExplicitUserIntent;
}
public static bool HasExplicitUserIntent(string userRequest, string toolName)
{
if (!RequiresExplicitUserIntent(toolName))
return true;
string request = userRequest.Trim().ToLowerInvariant();
return toolName.Equals("mcc_quit_client", StringComparison.OrdinalIgnoreCase)
&& (request.Contains("quit mcc", StringComparison.Ordinal)
|| request.Contains("close mcc", StringComparison.Ordinal)
|| request.Contains("stop mcc", StringComparison.Ordinal)
|| request.Contains("exit mcc", StringComparison.Ordinal)
|| request.Contains("quit the client", StringComparison.Ordinal)
|| request.Contains("stop the client", StringComparison.Ordinal));
}
private static bool IsVisible(MccToolProfile profile, MccWebHarnessOptions options)
{
if (!profile.VisibleByDefault)
{
if (profile.Name.Equals("mcc_inventory_window_action", StringComparison.OrdinalIgnoreCase))
return options.ExposeInventoryWindowAction;
if (profile.Name.Equals("mcc_run_internal_command", StringComparison.OrdinalIgnoreCase))
return options.ExposeInternalCommandTool;
return false;
}
return true;
}
private static object ToOpenRouterTool(McpClientTool tool, MccToolProfile profile)
{
JsonNode parameters = JsonNode.Parse(tool.JsonSchema.GetRawText()) ?? new JsonObject
{
["type"] = "object",
["properties"] = new JsonObject()
};
string description = tool.Description ?? string.Empty;
if (profile.Risk == MccToolRisk.Sensitive)
description = $"{description} Requires explicit user intent.";
else if (profile.Risk == MccToolRisk.EscapeHatch)
description = $"{description} Advanced escape hatch; prefer higher-level tools first.";
return new Dictionary<string, object?>
{
["type"] = "function",
["function"] = new Dictionary<string, object?>
{
["name"] = tool.Name,
["description"] = description,
["parameters"] = parameters
}
};
}
}

View file

@ -0,0 +1,58 @@
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccWebHarnessOptions
{
public const string SectionName = "MccWebHarness";
public string? Model { get; set; }
public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1";
public string McpEndpoint { get; set; } = "http://127.0.0.1:33333/mcp";
public int MaxTurns { get; set; } = 48;
public int MaxToolCalls { get; set; } = 120;
public int MaxWallClockSeconds { get; set; } = 240;
public int SoftFinishRemainingTurns { get; set; } = 3;
public int SoftFinishRemainingToolCalls { get; set; } = 8;
public int SoftFinishRemainingSeconds { get; set; } = 30;
public bool RequireProviderParameters { get; set; } = true;
public bool AllowFallbacks { get; set; }
public bool DisableParallelToolCalls { get; set; } = true;
public bool ExposeInventoryWindowAction { get; set; }
public bool ExposeInternalCommandTool { get; set; }
public string? ResolveModel()
{
return FirstNonEmpty(Environment.GetEnvironmentVariable("OPENROUTER_MODEL"), Model);
}
public string ResolveOpenRouterBaseUrl()
{
return FirstNonEmpty(Environment.GetEnvironmentVariable("OPENROUTER_BASE_URL"), OpenRouterBaseUrl)
?? "https://openrouter.ai/api/v1";
}
public string ResolveMcpEndpoint()
{
return FirstNonEmpty(Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT"), McpEndpoint)
?? "http://127.0.0.1:33333/mcp";
}
public string? ResolveMcpAuthToken()
{
return Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN");
}
public string? ResolveApiKey()
{
return Environment.GetEnvironmentVariable("OPENROUTER_API_KEY");
}
public bool HasApiKeyConfigured()
{
return !string.IsNullOrWhiteSpace(ResolveApiKey());
}
private static string? FirstNonEmpty(params string?[] candidates)
{
return candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate))?.Trim();
}
}

View file

@ -0,0 +1,200 @@
using System.Text;
using System.Text.Json;
using System.Reflection;
using DebugTools.MccMcpWebPlayground.Harness;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
public sealed class MccMcpSessionFactory
{
private readonly MccWebHarnessOptions options;
public MccMcpSessionFactory(IOptions<MccWebHarnessOptions> options)
{
this.options = options.Value;
}
public async Task<McpClient> CreateAsync(CancellationToken cancellationToken)
{
string endpoint = options.ResolveMcpEndpoint();
string? token = options.ResolveMcpAuthToken();
return await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(endpoint),
TransportMode = HttpTransportMode.AutoDetect,
AdditionalHeaders = string.IsNullOrWhiteSpace(token)
? null
: new Dictionary<string, string>
{
["Authorization"] = $"Bearer {token}"
}
}), cancellationToken: cancellationToken);
}
}
public static class MccMcpJson
{
public static MccNormalizedToolResult Normalize(CallToolResult result)
{
JsonElement? structuredRoot = TryReadStructuredContent(result);
string text = ReadToolResultText(result, structuredRoot);
try
{
using JsonDocument document = JsonDocument.Parse(text);
JsonElement parsedRoot = document.RootElement.Clone();
JsonElement root = ShouldPreferStructuredRoot(parsedRoot, structuredRoot)
? structuredRoot!.Value
: parsedRoot;
JsonElement? data = root.TryGetProperty("data", out JsonElement dataElement)
? dataElement.Clone()
: ShouldTreatRootAsData(root) ? root.Clone() : structuredRoot;
bool success = root.TryGetProperty("success", out JsonElement successElement)
? successElement.ValueKind != JsonValueKind.False
: result.IsError != true;
string? errorCode = root.TryGetProperty("errorCode", out JsonElement errorCodeElement) && errorCodeElement.ValueKind == JsonValueKind.String
? errorCodeElement.GetString()
: null;
string? message = root.TryGetProperty("message", out JsonElement messageElement) && messageElement.ValueKind == JsonValueKind.String
? messageElement.GetString()
: null;
bool isError = result.IsError == true || !success || !string.IsNullOrWhiteSpace(errorCode);
return new MccNormalizedToolResult(text, isError, success, errorCode, message, root, data);
}
catch
{
bool isError = result.IsError == true;
return new MccNormalizedToolResult(text, isError, !isError, null, null, structuredRoot, structuredRoot);
}
}
private static string ReadToolResultText(CallToolResult result, JsonElement? structuredRoot)
{
if (result.Content is null)
return structuredRoot?.GetRawText() ?? (result.IsError == true ? "{\"success\":false}" : "{\"success\":true}");
StringBuilder builder = new();
foreach (ContentBlock block in result.Content)
{
if (block is TextContentBlock text && !string.IsNullOrWhiteSpace(text.Text))
{
if (builder.Length > 0)
builder.Append('\n');
builder.Append(text.Text);
}
}
return builder.Length > 0
? builder.ToString()
: structuredRoot?.GetRawText()
?? JsonSerializer.Serialize(new { success = result.IsError != true, isError = result.IsError });
}
private static JsonElement? TryReadStructuredContent(CallToolResult result)
{
PropertyInfo? property = typeof(CallToolResult).GetProperty("StructuredContent", BindingFlags.Instance | BindingFlags.Public);
if (property?.GetValue(result) is not { } value)
return null;
return value switch
{
JsonElement json when json.ValueKind != JsonValueKind.Undefined && json.ValueKind != JsonValueKind.Null => json.Clone(),
JsonDocument document => document.RootElement.Clone(),
string text when !string.IsNullOrWhiteSpace(text) => TryParseJson(text),
_ => TrySerializeToJson(value)
};
}
private static JsonElement? TrySerializeToJson(object value)
{
try
{
return JsonSerializer.SerializeToElement(value);
}
catch
{
return null;
}
}
private static JsonElement? TryParseJson(string text)
{
try
{
using JsonDocument document = JsonDocument.Parse(text);
return document.RootElement.Clone();
}
catch
{
return null;
}
}
private static bool ShouldPreferStructuredRoot(JsonElement parsedRoot, JsonElement? structuredRoot)
{
if (structuredRoot is null)
return false;
if (parsedRoot.ValueKind != JsonValueKind.Object)
return true;
return !parsedRoot.EnumerateObject().Any(property =>
!property.NameEquals("success") &&
!property.NameEquals("isError"));
}
private static bool ShouldTreatRootAsData(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object)
return false;
return root.EnumerateObject().Any(property =>
!property.NameEquals("success") &&
!property.NameEquals("isError") &&
!property.NameEquals("errorCode") &&
!property.NameEquals("message"));
}
}
public static class MccJsonArguments
{
public static Dictionary<string, object?> Parse(string rawJson)
{
try
{
using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(rawJson) ? "{}" : rawJson);
if (document.RootElement.ValueKind != JsonValueKind.Object)
return new Dictionary<string, object?>();
Dictionary<string, object?> values = new(StringComparer.OrdinalIgnoreCase);
foreach (JsonProperty property in document.RootElement.EnumerateObject())
values[property.Name] = Convert(property.Value);
return values;
}
catch
{
return new Dictionary<string, object?>();
}
}
private static object? Convert(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Null => null,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Number => element.TryGetInt64(out long i64)
? i64
: element.TryGetDouble(out double d) ? d : element.GetRawText(),
JsonValueKind.String => element.GetString(),
JsonValueKind.Array => element.EnumerateArray().Select(Convert).ToArray(),
JsonValueKind.Object => element.EnumerateObject().ToDictionary(property => property.Name, property => Convert(property.Value)),
_ => element.GetRawText()
};
}
}

View file

@ -0,0 +1,120 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DebugTools.MccMcpWebPlayground.Harness;
namespace DebugTools.MccMcpWebPlayground.Infrastructure.OpenRouter;
public sealed class OpenRouterChatClient
{
private readonly IHttpClientFactory httpClientFactory;
public OpenRouterChatClient(IHttpClientFactory httpClientFactory)
{
this.httpClientFactory = httpClientFactory;
}
public async Task<MccModelTurn> CreateTurnAsync(
List<object> messages,
IReadOnlyList<object> tools,
MccWebHarnessOptions options,
CancellationToken cancellationToken)
{
string apiKey = options.ResolveApiKey() ?? throw new InvalidOperationException("OPENROUTER_API_KEY is not configured.");
string model = options.ResolveModel() ?? throw new InvalidOperationException("Model is not configured.");
using HttpClient client = httpClientFactory.CreateClient("openrouter");
client.BaseAddress = new Uri(options.ResolveOpenRouterBaseUrl().TrimEnd('/') + "/");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
client.DefaultRequestHeaders.TryAddWithoutValidation("HTTP-Referer", "https://localhost/mcc-mcp-web-playground");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-Title", "MCC MCP Web Playground");
Dictionary<string, object?> payload = new()
{
["model"] = model,
["messages"] = messages,
["tools"] = tools,
["tool_choice"] = "auto",
["provider"] = new Dictionary<string, object?>
{
["allow_fallbacks"] = options.AllowFallbacks,
["require_parameters"] = options.RequireProviderParameters
}
};
if (ShouldSendParallelToolCallsParameter(model))
payload["parallel_tool_calls"] = !options.DisableParallelToolCalls;
using HttpResponseMessage response = await client.PostAsync(
"chat/completions",
new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
cancellationToken);
string body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException($"OpenRouter returned HTTP {(int)response.StatusCode}: {body}");
using JsonDocument document = JsonDocument.Parse(body);
if (!document.RootElement.TryGetProperty("choices", out JsonElement choices)
|| choices.ValueKind != JsonValueKind.Array
|| choices.GetArrayLength() == 0)
{
throw new InvalidOperationException("OpenRouter did not return any choices.");
}
JsonElement message = choices[0].GetProperty("message");
string assistantContent = message.TryGetProperty("content", out JsonElement contentElement)
? contentElement.GetString() ?? string.Empty
: string.Empty;
List<MccModelToolCall> toolCalls = [];
if (message.TryGetProperty("tool_calls", out JsonElement toolCallsElement) && toolCallsElement.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement toolCall in toolCallsElement.EnumerateArray())
{
if (!toolCall.TryGetProperty("id", out JsonElement idElement)
|| !toolCall.TryGetProperty("function", out JsonElement functionElement)
|| !functionElement.TryGetProperty("name", out JsonElement nameElement))
{
continue;
}
toolCalls.Add(new MccModelToolCall(
CallId: idElement.GetString() ?? Guid.NewGuid().ToString("n"),
Name: nameElement.GetString() ?? string.Empty,
ArgumentsJson: functionElement.TryGetProperty("arguments", out JsonElement argumentsElement)
? argumentsElement.GetString() ?? "{}"
: "{}"));
}
}
string modelId = document.RootElement.TryGetProperty("model", out JsonElement modelElement)
? modelElement.GetString() ?? model
: model;
string? routedProvider = response.Headers.TryGetValues("x-openrouter-provider", out IEnumerable<string>? providerValues)
? providerValues.FirstOrDefault()
: null;
return new MccModelTurn(modelId, routedProvider, assistantContent, toolCalls);
}
private static bool ShouldSendParallelToolCallsParameter(string model)
{
// Some OpenRouter model families reject tool-enabled requests when the parallel_tool_calls
// parameter is present at all, even if it is explicitly set to false. The harness still
// executes all returned tool calls sequentially, so omitting the transport hint for those
// families preserves the intended runtime behavior while keeping the stricter flag for
// compatible models.
return !model.StartsWith("minimax/", StringComparison.OrdinalIgnoreCase)
&& !model.StartsWith("google/gemini-", StringComparison.OrdinalIgnoreCase);
}
}
public sealed record MccModelTurn(
string ModelId,
string? RoutedProvider,
string AssistantContent,
IReadOnlyList<MccModelToolCall> ToolCalls);
public sealed record MccModelToolCall(string CallId, string Name, string ArgumentsJson);

File diff suppressed because it is too large Load diff

View file

@ -4,5 +4,11 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"MccWebHarness": {
"AllowFallbacks": false,
"DisableParallelToolCalls": true,
"ExposeInventoryWindowAction": false,
"ExposeInternalCommandTool": false
} }
} }

View file

@ -5,5 +5,20 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"MccWebHarness": {
"OpenRouterBaseUrl": "https://openrouter.ai/api/v1",
"McpEndpoint": "http://127.0.0.1:33333/mcp",
"MaxTurns": 48,
"MaxToolCalls": 120,
"MaxWallClockSeconds": 240,
"SoftFinishRemainingTurns": 3,
"SoftFinishRemainingToolCalls": 8,
"SoftFinishRemainingSeconds": 30,
"RequireProviderParameters": true,
"AllowFallbacks": false,
"DisableParallelToolCalls": true,
"ExposeInventoryWindowAction": false,
"ExposeInternalCommandTool": false
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }

View file

@ -0,0 +1,310 @@
const html = document.documentElement;
const statusEl = document.getElementById("status");
const sendBtn = document.getElementById("send");
const stopBtn = document.getElementById("stop");
const clearBtn = document.getElementById("clear");
const clearChatBtn = document.getElementById("clear-chat-btn");
const clearToolsBtn = document.getElementById("clear-tools-btn");
const promptEl = document.getElementById("prompt");
const chatEl = document.getElementById("chat");
const toolsEl = document.getElementById("tools");
const emptyStateEl = document.getElementById("empty-state");
const toolsEmptyStateEl = document.getElementById("tools-empty-state");
const typingIndicatorEl = document.getElementById("typing-indicator");
const themeToggleBtn = document.getElementById("theme-toggle");
const themeToggleIconEl = document.getElementById("theme-toggle-icon");
let history = [];
let activeAssistantBody = null;
let abortController = null;
stopBtn.disabled = true;
loadTheme();
loadConfig();
themeToggleBtn.addEventListener("click", () => {
const next = html.getAttribute("data-theme") === "dark" ? "light" : "dark";
setTheme(next);
});
sendBtn.addEventListener("click", sendPrompt);
stopBtn.addEventListener("click", () => abortController?.abort());
clearBtn.addEventListener("click", () => {
history = [];
removeAllMessages();
removeAllTimelineEvents();
promptEl.value = "";
activeAssistantBody = null;
updateEmptyStates();
});
clearChatBtn.addEventListener("click", () => {
history = [];
removeAllMessages();
promptEl.value = "";
activeAssistantBody = null;
updateEmptyStates();
});
clearToolsBtn.addEventListener("click", () => {
removeAllTimelineEvents();
updateEmptyStates();
});
promptEl.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendPrompt();
}
});
async function loadConfig() {
try {
const response = await fetch("/api/config");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const config = await response.json();
const modelLabel = config.model ? config.model : "Model not configured";
statusEl.textContent = config.hasApiKey ? modelLabel : `${modelLabel} / missing OPENROUTER_API_KEY`;
} catch (error) {
statusEl.textContent = `Config error: ${error.message}`;
}
}
async function sendPrompt() {
const prompt = promptEl.value.trim();
if (!prompt || abortController) {
return;
}
history.push({ role: "user", content: prompt });
addMessage("user", prompt);
promptEl.value = "";
activeAssistantBody = addMessage("assistant", "");
setBusy(true);
abortController = new AbortController();
try {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history }),
signal: abortController.signal
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let finalAssistantText = "";
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
buffer = parseSseChunk(buffer, (eventName, envelope) => {
addTimelineEvent(eventName, envelope);
if (eventName === "error") {
const errorMessage = envelope.data?.message ?? "Unknown error";
addMessage("error", errorMessage);
}
if (eventName === "final") {
finalAssistantText = formatFinalText(envelope.data);
activeAssistantBody.textContent = finalAssistantText;
}
if (eventName === "state_summary") {
const turnCount = envelope.data?.turnCount ?? "?";
const toolCallCount = envelope.data?.toolCallCount ?? "?";
statusEl.textContent = `Running turn ${turnCount}, tools ${toolCallCount}`;
}
});
}
if (finalAssistantText.trim().length > 0) {
history.push({ role: "assistant", content: finalAssistantText });
}
} catch (error) {
if (error.name !== "AbortError") {
addMessage("error", `Request failed: ${error.message}`);
addTimelineEvent("error", {
kind: "error",
data: {
code: "request_failed",
message: error.message
}
});
}
} finally {
abortController = null;
activeAssistantBody = null;
setBusy(false);
}
}
function parseSseChunk(buffer, onEvent) {
let blockIndex;
while ((blockIndex = buffer.indexOf("\n\n")) >= 0) {
const rawBlock = buffer.slice(0, blockIndex);
buffer = buffer.slice(blockIndex + 2);
let eventName = "message";
let dataText = "";
for (const line of rawBlock.split("\n")) {
if (line.startsWith("event:")) {
eventName = line.slice(6).trim();
} else if (line.startsWith("data:")) {
dataText += line.slice(5).trim();
}
}
if (!dataText) {
continue;
}
try {
onEvent(eventName, JSON.parse(dataText));
} catch (error) {
onEvent("error", {
kind: "error",
data: {
code: "invalid_sse_payload",
message: "Failed to parse SSE payload.",
detail: dataText
}
});
}
}
return buffer;
}
function addMessage(role, content) {
const wrapper = document.createElement("div");
wrapper.className = `message ${role}`;
const label = document.createElement("div");
label.className = "message-label";
label.textContent = role;
const body = document.createElement("div");
body.className = "message-body";
body.textContent = content;
wrapper.append(label, body);
chatEl.insertBefore(wrapper, typingIndicatorEl);
chatEl.scrollTop = chatEl.scrollHeight;
updateEmptyStates();
return body;
}
function addTimelineEvent(kind, envelope) {
const event = document.createElement("div");
event.className = `timeline-event kind-${kind}`;
const label = document.createElement("div");
label.className = "timeline-label";
label.textContent = kind.replaceAll("_", " ");
const body = document.createElement("div");
body.className = "timeline-body-text";
body.textContent = JSON.stringify(envelope.data ?? envelope, null, 2);
event.append(label, body);
toolsEl.appendChild(event);
toolsEl.scrollTop = toolsEl.scrollHeight;
updateEmptyStates();
}
function formatFinalText(data) {
if (!data) {
return "The run completed without a final payload.";
}
const lines = [];
if (data.headline) {
lines.push(data.headline);
lines.push("");
}
if (data.answerMarkdown) {
lines.push(data.answerMarkdown);
}
if (Array.isArray(data.verifiedFacts) && data.verifiedFacts.length > 0) {
lines.push("");
lines.push("Verified facts:");
for (const fact of data.verifiedFacts) {
lines.push(`- ${fact}`);
}
}
if (Array.isArray(data.openIssues) && data.openIssues.length > 0) {
lines.push("");
lines.push("Open issues:");
for (const issue of data.openIssues) {
lines.push(`- ${issue}`);
}
}
if (data.nextAction) {
lines.push("");
lines.push(`Next action: ${data.nextAction}`);
}
return lines.join("\n");
}
function setBusy(busy) {
sendBtn.disabled = busy;
stopBtn.disabled = !busy;
promptEl.disabled = busy;
typingIndicatorEl.classList.toggle("visible", busy);
statusEl.classList.toggle("busy", busy);
if (!busy) {
loadConfig();
} else {
statusEl.textContent = "Streaming run...";
}
}
function removeAllMessages() {
for (const message of chatEl.querySelectorAll(".message")) {
message.remove();
}
}
function removeAllTimelineEvents() {
for (const event of toolsEl.querySelectorAll(".timeline-event")) {
event.remove();
}
}
function updateEmptyStates() {
emptyStateEl.style.display = chatEl.querySelectorAll(".message").length === 0 ? "" : "none";
toolsEmptyStateEl.style.display = toolsEl.querySelectorAll(".timeline-event").length === 0 ? "" : "none";
}
function loadTheme() {
const theme = localStorage.getItem("mcc-playground-theme") || "dark";
setTheme(theme);
}
function setTheme(theme) {
html.setAttribute("data-theme", theme);
themeToggleIconEl.textContent = theme === "dark" ? "◎" : "◐";
localStorage.setItem("mcc-playground-theme", theme);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,383 @@
:root {
--bg: #07111e;
--bg-alt: #0b1828;
--panel: rgba(10, 21, 36, 0.88);
--panel-strong: rgba(8, 18, 30, 0.96);
--border: rgba(111, 179, 255, 0.18);
--text: #dce9ff;
--text-dim: #8ca4c8;
--text-soft: #607695;
--accent: #75e7c7;
--accent-strong: #4ad3ff;
--warning: #ffcc66;
--danger: #ff7b8b;
--shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
[data-theme="light"] {
--bg: #edf4ff;
--bg-alt: #dfeaff;
--panel: rgba(255, 255, 255, 0.88);
--panel-strong: rgba(255, 255, 255, 0.96);
--border: rgba(28, 89, 164, 0.14);
--text: #172843;
--text-dim: #4d6383;
--text-soft: #7d90ad;
--accent: #0f936d;
--accent-strong: #006cbb;
--warning: #a56700;
--danger: #ba2741;
--shadow: 0 20px 60px rgba(61, 89, 138, 0.12);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
display: grid;
grid-template-rows: auto 1fr auto;
gap: 16px;
padding: 18px;
color: var(--text);
font-family: "Space Mono", monospace;
background:
radial-gradient(circle at top left, rgba(74, 211, 255, 0.12), transparent 35%),
radial-gradient(circle at right center, rgba(117, 231, 199, 0.08), transparent 40%),
linear-gradient(160deg, var(--bg), var(--bg-alt));
}
button,
textarea {
font: inherit;
}
.topbar,
.panel,
.composer {
border: 1px solid var(--border);
border-radius: 18px;
background: var(--panel);
backdrop-filter: blur(18px);
box-shadow: var(--shadow);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-dot {
width: 12px;
height: 12px;
border-radius: 999px;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
box-shadow: 0 0 18px rgba(117, 231, 199, 0.55);
}
.brand-title {
font-family: "Syne", sans-serif;
font-size: 1rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.brand-title span {
color: var(--accent);
}
.brand-subtitle {
margin-top: 4px;
color: var(--text-dim);
font-size: 0.74rem;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.status-pill {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-dim);
font-size: 0.72rem;
white-space: nowrap;
}
.status-pill.busy {
color: var(--accent-strong);
border-color: rgba(74, 211, 255, 0.4);
}
.icon-button,
.ghost-button,
.primary-button {
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.02);
color: var(--text);
padding: 10px 14px;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.icon-button:hover,
.ghost-button:hover,
.primary-button:hover {
transform: translateY(-1px);
border-color: rgba(117, 231, 199, 0.4);
}
.primary-button {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #06101a;
font-weight: 700;
}
.layout {
min-height: 0;
display: grid;
grid-template-columns: 1.2fr 0.9fr;
gap: 16px;
}
.panel {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
}
.panel-header h1 {
margin: 0;
font-family: "Syne", sans-serif;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.panel-body {
min-height: 0;
overflow: auto;
padding: 18px;
}
.chat-body,
.timeline-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.message,
.timeline-event {
border-radius: 16px;
border: 1px solid var(--border);
padding: 14px 16px;
background: var(--panel-strong);
}
.message.user {
background: rgba(74, 211, 255, 0.09);
}
.message.assistant {
background: rgba(117, 231, 199, 0.06);
}
.message.error {
background: rgba(255, 123, 139, 0.08);
border-color: rgba(255, 123, 139, 0.2);
}
.message-label,
.timeline-label {
margin-bottom: 8px;
color: var(--text-dim);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.message-body,
.timeline-body-text {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
font-size: 0.82rem;
}
.timeline-event.kind-tool_called {
border-left: 4px solid var(--accent-strong);
}
.timeline-event.kind-tool_result {
border-left: 4px solid var(--accent);
}
.timeline-event.kind-error {
border-left: 4px solid var(--danger);
}
.timeline-event.kind-budget {
border-left: 4px solid var(--warning);
}
.timeline-event.kind-final {
border-left: 4px solid var(--accent);
}
.empty-state {
padding: 30px 18px;
text-align: center;
color: var(--text-soft);
border: 1px dashed var(--border);
border-radius: 14px;
}
.typing-indicator {
display: none;
gap: 6px;
align-items: center;
padding: 10px 4px 0;
}
.typing-indicator.visible {
display: flex;
}
.typing-indicator span {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--accent-strong);
animation: bounce 1s ease-in-out infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.16s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.32s;
}
@keyframes bounce {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.45;
}
40% {
transform: translateY(-5px);
opacity: 1;
}
}
.composer {
padding: 16px 18px;
}
.composer-row {
display: flex;
}
#prompt {
width: 100%;
min-height: 78px;
max-height: 240px;
resize: vertical;
border-radius: 16px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.12);
color: var(--text);
padding: 14px 16px;
}
#prompt:focus {
outline: 2px solid rgba(74, 211, 255, 0.35);
border-color: rgba(74, 211, 255, 0.45);
}
.composer-footer {
margin-top: 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.composer-hint {
color: var(--text-dim);
font-size: 0.74rem;
}
kbd {
padding: 2px 6px;
border-radius: 6px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.03);
font-size: 0.74rem;
}
.composer-actions {
display: flex;
gap: 10px;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
body {
padding: 12px;
}
.topbar,
.composer {
padding: 14px;
}
.composer-footer {
flex-direction: column;
align-items: stretch;
}
.composer-actions {
justify-content: stretch;
}
.composer-actions > button {
flex: 1;
}
}

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Security.Cryptography;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
@ -25,6 +26,8 @@ public sealed class MccMcpGuidanceProvider
public string PromptName => "mcc_operator_prompt"; public string PromptName => "mcc_operator_prompt";
public string SkillName => "mcc-mcp-operator";
public string GetSystemPrompt() public string GetSystemPrompt()
{ {
GuidanceDocument document = guidanceDocument.Value; GuidanceDocument document = guidanceDocument.Value;
@ -51,6 +54,9 @@ public sealed class MccMcpGuidanceProvider
{ {
PromptName = PromptName, PromptName = PromptName,
PromptMarkdown = document.PromptMarkdown, PromptMarkdown = document.PromptMarkdown,
SkillName = SkillName,
GuidanceVersion = document.GuidanceVersion,
SkillMarkdown = document.SkillMarkdown,
SystemPrompt = GetSystemPrompt(), SystemPrompt = GetSystemPrompt(),
BestPractices = document.BestPractices, BestPractices = document.BestPractices,
ExampleScenarios = document.ExampleScenarios, ExampleScenarios = document.ExampleScenarios,
@ -76,6 +82,7 @@ public sealed class MccMcpGuidanceProvider
string exampleScenariosSection = ExtractSection(bodyMarkdown, ExampleScenariosHeading); string exampleScenariosSection = ExtractSection(bodyMarkdown, ExampleScenariosHeading);
return new GuidanceDocument( return new GuidanceDocument(
ComputeGuidanceVersion(promptMarkdown),
promptMarkdown.Replace("\r\n", "\n").Trim(), promptMarkdown.Replace("\r\n", "\n").Trim(),
bodyMarkdown, bodyMarkdown,
ExtractBulletList(bestPracticesSection), ExtractBulletList(bestPracticesSection),
@ -180,11 +187,22 @@ public sealed class MccMcpGuidanceProvider
return enabled ? "enabled" : "disabled"; return enabled ? "enabled" : "disabled";
} }
private static string ComputeGuidanceVersion(string markdown)
{
byte[] bytes = Encoding.UTF8.GetBytes(markdown.Replace("\r\n", "\n").Trim());
byte[] hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash[..8]).ToLowerInvariant();
}
private sealed record GuidanceDocument( private sealed record GuidanceDocument(
string PromptMarkdown, string GuidanceVersion,
string SkillMarkdown,
string BodyMarkdown, string BodyMarkdown,
string[] BestPractices, string[] BestPractices,
MccMcpAgentScenario[] ExampleScenarios); MccMcpAgentScenario[] ExampleScenarios)
{
public string PromptMarkdown => SkillMarkdown;
}
} }
public sealed class MccMcpAgentGuidancePayload public sealed class MccMcpAgentGuidancePayload
@ -195,6 +213,15 @@ public sealed class MccMcpAgentGuidancePayload
[JsonPropertyName("promptMarkdown")] [JsonPropertyName("promptMarkdown")]
public string PromptMarkdown { get; init; } = string.Empty; public string PromptMarkdown { get; init; } = string.Empty;
[JsonPropertyName("skillName")]
public string SkillName { get; init; } = string.Empty;
[JsonPropertyName("guidanceVersion")]
public string GuidanceVersion { get; init; } = string.Empty;
[JsonPropertyName("skillMarkdown")]
public string SkillMarkdown { get; init; } = string.Empty;
[JsonPropertyName("systemPrompt")] [JsonPropertyName("systemPrompt")]
public string SystemPrompt { get; init; } = string.Empty; public string SystemPrompt { get; init; } = string.Empty;