feat: MCP-Tools ask_user_choice + memory_read/write/list, Text-Marker entfernt
MCP-Server erweitert um ask_user_choice (Long-Poll), memory_read, memory_write und memory_list Tools. MCP-Config wird jetzt immer generiert (nicht nur bei Remote-Verbindung). InternalEndpoint um PendingChoices + choice-respond Endpoint ergänzt. Frontend differenziert CLI-Modus (choice-respond API) vs. API-Modus. Gesamter :::ASK_USER_CHOICE::: Text-Marker-Mechanismus aus ClaudeCliService, AnthropicService, markdown.js und stream-ui.js entfernt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
3b068a7866
commit
3e0a69e7a4
10 files changed
+493
-319
No files matched your search
@@ -1,3 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
@@ -19,11 +21,22 @@ public static class InternalEndpoint
|
||||
/// <summary>
|
||||
/// Interner Auth-Token – wird beim Start generiert und an MCP-Server übergeben.
|
||||
/// </summary>
|
||||
public static string InternalToken { get; } = System.Guid.NewGuid().ToString("N");
|
||||
public static string InternalToken { get; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Pending user choices – Key ist sessionId, Value wird completed wenn der User klickt.
|
||||
/// </summary>
|
||||
public static readonly ConcurrentDictionary<string, TaskCompletionSource<string>> PendingChoices = new();
|
||||
|
||||
public static void MapInternalEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/api/internal/remote-execute", HandleRemoteExecute);
|
||||
app.MapPost("/api/internal/ask-user-choice", HandleAskUserChoice);
|
||||
// choice-respond ist NICHT unter /api/internal/ weil es vom Frontend aufgerufen wird (normale Auth)
|
||||
app.MapPost("/api/chat/choice-respond", HandleChoiceRespond);
|
||||
app.MapPost("/api/internal/memory-read", HandleMemoryRead);
|
||||
app.MapPost("/api/internal/memory-write", HandleMemoryWrite);
|
||||
app.MapPost("/api/internal/memory-list", HandleMemoryList);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleRemoteExecute(HttpContext ctx, RemoteConnectionManager mgr)
|
||||
@@ -70,4 +83,194 @@ public static class InternalEndpoint
|
||||
return Results.Json(new { error = ex.Message, stackTrace = ex.StackTrace }, statusCode: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MCP-Server ruft diesen Endpoint auf und blockiert bis der User eine Auswahl trifft.
|
||||
/// Der TCS wird vorher von AnthropicService erstellt (beim tool_use Event).
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleAskUserChoice(HttpContext ctx)
|
||||
{
|
||||
try
|
||||
{
|
||||
var denied = CheckToken(ctx);
|
||||
if (denied != null) return denied;
|
||||
|
||||
using var reader = new StreamReader(ctx.Request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
JsonElement json;
|
||||
try { json = JsonSerializer.Deserialize<JsonElement>(body); }
|
||||
catch { return Results.BadRequest(new { error = "Invalid JSON" }); }
|
||||
|
||||
var sessionId = json.TryGetProperty("sessionId", out var sid) ? sid.GetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(sessionId))
|
||||
return Results.BadRequest(new { error = "sessionId erforderlich" });
|
||||
|
||||
// Warte auf den TCS der von AnthropicService erstellt wurde
|
||||
if (!PendingChoices.TryGetValue(sessionId, out var tcs))
|
||||
{
|
||||
// TCS noch nicht erstellt (Race Condition) – kurz warten und nochmal probieren
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await Task.Delay(500, ctx.RequestAborted);
|
||||
if (PendingChoices.TryGetValue(sessionId, out tcs)) break;
|
||||
}
|
||||
if (tcs == null)
|
||||
return Results.Json(new { error = "Keine ausstehende Auswahl für diese Session" }, statusCode: 404);
|
||||
}
|
||||
|
||||
// Blockieren bis User antwortet (max 5 Minuten)
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted);
|
||||
cts.CancelAfter(TimeSpan.FromMinutes(5));
|
||||
|
||||
try
|
||||
{
|
||||
var choice = await tcs.Task.WaitAsync(cts.Token);
|
||||
PendingChoices.TryRemove(sessionId, out _);
|
||||
return Results.Ok(new { choice });
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
PendingChoices.TryRemove(sessionId, out _);
|
||||
return Results.Json(new { error = "Timeout – keine Auswahl getroffen" }, statusCode: 408);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frontend ruft diesen Endpoint auf wenn der User einen Choice-Button klickt (CLI-Modus).
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleChoiceRespond(HttpContext ctx)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Kein Token-Check – wird über normale Auth-Middleware geschützt (Cookie-basiert)
|
||||
using var reader = new StreamReader(ctx.Request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
JsonElement json;
|
||||
try { json = JsonSerializer.Deserialize<JsonElement>(body); }
|
||||
catch { return Results.BadRequest(new { error = "Invalid JSON" }); }
|
||||
|
||||
var sessionId = json.TryGetProperty("sessionId", out var sid) ? sid.GetString() ?? "" : "";
|
||||
var choice = json.TryGetProperty("choice", out var c) ? c.GetString() ?? "" : "";
|
||||
|
||||
if (string.IsNullOrEmpty(sessionId))
|
||||
return Results.BadRequest(new { error = "sessionId erforderlich" });
|
||||
if (string.IsNullOrEmpty(choice))
|
||||
return Results.BadRequest(new { error = "choice erforderlich" });
|
||||
|
||||
if (PendingChoices.TryGetValue(sessionId, out var tcs))
|
||||
{
|
||||
tcs.TrySetResult(choice);
|
||||
return Results.Ok(new { success = true });
|
||||
}
|
||||
|
||||
return Results.NotFound(new { error = "Keine ausstehende Auswahl für diese Session" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: 500);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult CheckToken(HttpContext ctx)
|
||||
{
|
||||
var token = ctx.Request.Headers["X-Internal-Token"].FirstOrDefault() ?? "";
|
||||
return token != InternalToken ? Results.StatusCode(403) : null;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleMemoryRead(HttpContext ctx, MemoryService memoryService)
|
||||
{
|
||||
try
|
||||
{
|
||||
var denied = CheckToken(ctx);
|
||||
if (denied != null) return denied;
|
||||
|
||||
using var reader = new StreamReader(ctx.Request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
JsonElement json;
|
||||
try { json = JsonSerializer.Deserialize<JsonElement>(body); }
|
||||
catch { return Results.BadRequest(new { error = "Invalid JSON" }); }
|
||||
|
||||
var agentId = json.TryGetProperty("agentId", out var a) ? a.GetString() ?? "" : "";
|
||||
var path = json.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
||||
|
||||
if (string.IsNullOrEmpty(agentId)) return Results.BadRequest(new { error = "agentId erforderlich" });
|
||||
if (string.IsNullOrEmpty(path)) return Results.BadRequest(new { error = "path erforderlich" });
|
||||
|
||||
var content = memoryService.ReadMemoryFile(agentId, path);
|
||||
if (content == null)
|
||||
return Results.NotFound(new { error = $"Datei nicht gefunden: {path}" });
|
||||
|
||||
return Results.Ok(new { content });
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: 500);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleMemoryWrite(HttpContext ctx, MemoryService memoryService)
|
||||
{
|
||||
try
|
||||
{
|
||||
var denied = CheckToken(ctx);
|
||||
if (denied != null) return denied;
|
||||
|
||||
using var reader = new StreamReader(ctx.Request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
JsonElement json;
|
||||
try { json = JsonSerializer.Deserialize<JsonElement>(body); }
|
||||
catch { return Results.BadRequest(new { error = "Invalid JSON" }); }
|
||||
|
||||
var agentId = json.TryGetProperty("agentId", out var a) ? a.GetString() ?? "" : "";
|
||||
var path = json.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
||||
var content = json.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
var append = json.TryGetProperty("append", out var ap) && ap.GetBoolean();
|
||||
|
||||
if (string.IsNullOrEmpty(agentId)) return Results.BadRequest(new { error = "agentId erforderlich" });
|
||||
if (string.IsNullOrEmpty(path)) return Results.BadRequest(new { error = "path erforderlich" });
|
||||
|
||||
if (append)
|
||||
memoryService.AppendMemoryFile(agentId, path, content);
|
||||
else
|
||||
memoryService.WriteMemoryFile(agentId, path, content);
|
||||
|
||||
return Results.Ok(new { success = true, message = $"Memory-Datei {(append ? "erweitert" : "geschrieben")}: {path} ({content.Length} Zeichen)" });
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: 500);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleMemoryList(HttpContext ctx, MemoryService memoryService)
|
||||
{
|
||||
try
|
||||
{
|
||||
var denied = CheckToken(ctx);
|
||||
if (denied != null) return denied;
|
||||
|
||||
using var reader = new StreamReader(ctx.Request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
JsonElement json;
|
||||
try { json = JsonSerializer.Deserialize<JsonElement>(body); }
|
||||
catch { return Results.BadRequest(new { error = "Invalid JSON" }); }
|
||||
|
||||
var agentId = json.TryGetProperty("agentId", out var a) ? a.GetString() ?? "" : "";
|
||||
var subPath = json.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
||||
|
||||
if (string.IsNullOrEmpty(agentId)) return Results.BadRequest(new { error = "agentId erforderlich" });
|
||||
|
||||
var files = memoryService.ListMemoryFiles(agentId, subPath);
|
||||
return Results.Ok(new { files });
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user