Files
gott/Services/AnthropicService.Cli.cs
T
MelekiandClaude Opus 4.7 3f913f14b5 Add per-agent reasoning effort
Opus 4.5+ and Sonnet 4.6+ support a new "effort" knob that controls how
much budget the model spends on thinking and tool calls. Plumb it through
end to end so each agent can pick its own level.

Storage & shape:
- New TEXT column agents.effort (default '') added via TryAddColumn.
- AgentRecord.Effort + INSERT/UPDATE in DatabaseService.Agents.cs.
- ChatContext.Effort + SubAgentContext.Effort so the value travels with the
  request alongside model and tool settings.

Agent editor:
- Effort dropdown above git-commit behavior in the agent form (low/medium/
  high/xhigh/max plus empty = "use default"). Always visible; on OpenAI
  providers the value is sent but silently ignored.
- agent-page.js load/save and AgentEndpoint create/update wired up.

Anthropic API path:
- StreamChat and RunNonStreamingToolLoop add output_config: { effort } when
  effort is set and not "high" – empty/null/"high" means "omit and let the
  API pick its default".
- Sub-agents (preparer/review/finalizer) get the parameter through
  RunAgentChat so each step uses its own agent's effort.

Anthropic CLI path:
- ClaudeCliService.ChatStreamingAsync takes an effort parameter and exports
  CLAUDE_CODE_EFFORT_LEVEL on the spawned process. Env-var beats the
  --effort flag because it also covers "max" and "ultracode".
- StreamChatViaCli, ChatNonStreamingViaCli, RunSubAgentViaCli, and the
  ChatEndpoint anthropic branches all hand the value through.

ARCHITECTURE.md / ARCHITECTURE_QUICK.md updated with the new field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 21:33:32 +02:00

813 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Gott.Endpoints;
using Gott.Models;
using Microsoft.AspNetCore.Http;
namespace Gott.Services;
public partial class AnthropicService
{
private static string _cachedPythonCmd;
private static bool _pythonCmdChecked;
/// <summary>
/// Streaming-Chat über die Claude CLI.
/// Die CLI verwendet das lokale Token aus ~/.claude/.credentials.json.
/// </summary>
public async Task StreamChatViaCli(
HttpResponse response,
string modelId,
List<object> systemPrompt,
List<object> messages,
string chainId,
string workspace = null,
bool allowUserChoice = false,
bool planMode = false,
bool webSearch = false,
string sessionId = null,
string agentId = null,
RemoteConnectionManager remoteMgr = null,
string unityMcpUrl = null,
string unityMcpToken = null,
bool useAutoContext = true,
string effort = null,
CancellationToken workCt = default,
CancellationToken sseCt = default)
{
if (_cliService == null || !_cliService.IsAvailable)
{
await SseHelper.TryWriteSse(response, "error", new { error = "Claude CLI nicht verfügbar" }, sseCt);
await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt);
return;
}
var sw = System.Diagnostics.Stopwatch.StartNew();
var usage = new TokenUsage();
var fullText = new StringBuilder();
var tempImagePaths = new List<string>();
string mcpConfigPath = null;
bool isUserChoice = false;
var chain = new ChainRecord
{
ChainId = chainId,
SessionId = sessionId,
Model = modelId
};
try
{
// MCP-Config generieren (immer – für ask_user_choice, optional Memory-Tools + remote_execute)
var remoteConn = remoteMgr != null && !string.IsNullOrEmpty(sessionId) ? remoteMgr.GetBySessionId(sessionId) : null;
var hasRemote = remoteConn != null;
var remoteOs = remoteConn?.OsVersion ?? "";
mcpConfigPath = GenerateMcpConfig(sessionId, agentId, hasRemote, unityMcpUrl, unityMcpToken, remoteOs);
// Python-Verfügbarkeit prüfen und ggf. warnen
if (FindPythonCommand() == null)
await SseHelper.TryWriteSse(response, "warning", new { message = "⚠ Python wurde nicht gefunden (python3/python/py). MCP-Tools (Remote-Execute, Memory, etc.) sind nicht verfügbar." }, sseCt);
// System-Prompt als Text extrahieren
var systemText = new StringBuilder();
if (systemPrompt != null)
{
foreach (var block in systemPrompt)
{
var json = JsonSerializer.Serialize(block);
var el = JsonSerializer.Deserialize<JsonElement>(json);
if (el.TryGetProperty("text", out var t))
systemText.AppendLine(t.GetString());
}
}
// ── Automatische Kontext-Injektion via Embedding-Suche (wie in StreamChat) ──
if (useAutoContext && messages.Count > 0)
{
try
{
var autoCtx = await BuildAutoContextAsync(_vectorSearch, messages, chainId, agentId);
if (autoCtx != null)
{
systemText.AppendLine(autoCtx);
await SseHelper.TryWriteSse(response, "auto_context", new { context = autoCtx }, sseCt);
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Role = "system",
Type = "auto_context",
Content = autoCtx
});
}
}
catch (Exception ex)
{
Console.WriteLine($"[AutoContext/CLI] Error: {ex.Message}");
}
}
// Letzte User-Message als Prompt extrahieren
var prompt = ExtractLastUserText(messages) ?? "";
// Bild-Attachments aus letzter User-Message als Temp-Dateien speichern
// (CLI kann Bilder nicht inline empfangen, aber per Read-Tool lesen)
var imageRefs = ExtractLastUserImageRefs(messages, tempImagePaths);
if (!string.IsNullOrEmpty(imageRefs))
{
prompt = imageRefs + "\n" + prompt;
}
// Konversations-History als Kontext zum Prompt hinzufügen
var historyContext = BuildHistoryContext(messages);
if (!string.IsNullOrEmpty(historyContext))
{
prompt = $"<conversation_history>\n{historyContext}\n</conversation_history>\n\n<current_request>\n{prompt}\n</current_request>";
}
string choiceToolCallId = null;
var result = await _cliService.ChatStreamingAsync(
prompt,
async evt =>
{
switch (evt.Type)
{
case CliEventType.TextStart:
await SseHelper.TryWriteSse(response, "text_start", new { index = 0 }, sseCt);
break;
case CliEventType.TextDelta:
fullText.Append(evt.Text);
await SseHelper.TryWriteSse(response, "text_delta", new { text = evt.Text }, sseCt);
break;
case CliEventType.TextEnd:
await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt);
break;
case CliEventType.ToolStart:
{
// Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB)
if (fullText.Length > 0)
{
var textForEmbed = fullText.ToString();
var msgId = await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "assistant",
Type = "text",
Content = textForEmbed
});
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", textForEmbed);
fullText.Clear();
}
var mappedName = MapCliToolName(evt.ToolName);
await SseHelper.TryWriteSse(response, "tool_start", new { name = mappedName, id = evt.ToolId }, sseCt);
break;
}
case CliEventType.ToolEnd:
{
var mappedName = MapCliToolName(evt.ToolName);
var mappedInput = MapCliToolInput(evt.ToolName, evt.ToolInput);
// Tool-Use in DB speichern (damit Poll die Tools anzeigt)
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "assistant",
Type = "tool_use",
ToolName = mappedName,
ToolCallId = evt.ToolId,
Content = mappedInput
});
await SseHelper.TryWriteSse(response, "tool_end", new { name = mappedName, id = evt.ToolId, input = mappedInput }, sseCt);
// ask_user_choice via MCP: TCS erstellen + SSE user_choice Event
if (mappedName == "ask_user_choice")
{
try
{
var choiceInput = JsonSerializer.Deserialize<JsonElement>(mappedInput);
var cQuestion = choiceInput.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
var cChoices = new List<string>();
if (choiceInput.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
foreach (var cOpt in cc.EnumerateArray())
cChoices.Add(cOpt.GetString() ?? "");
// Alte Antwort entfernen (falls vorhanden)
InternalEndpoint.ChoiceAnswers.TryRemove(sessionId, out _);
// SSE Event mit viaCli-Flag → Frontend zeigt Buttons + nutzt choice-respond Endpoint
choiceToolCallId = evt.ToolId;
await SseHelper.TryWriteSse(response, "user_choice", new
{
toolCallId = evt.ToolId,
question = cQuestion,
choices = cChoices,
viaCli = true
}, sseCt);
isUserChoice = true;
}
catch (JsonException) { /* Input nicht parsbar – MCP-Server gibt Fehler */ }
}
break;
}
case CliEventType.ToolResult:
{
// Tool-Result in DB speichern (damit es beim Neuladen sichtbar ist)
var resultContent = SseHelper.TruncateToolResult(evt.ToolResultContent ?? "");
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "user",
Type = "tool_result",
ToolCallId = evt.ToolId,
Content = resultContent,
IsError = evt.ToolResultIsError
});
// SSE Event an Client senden
await SseHelper.TryWriteSse(response, "tool_result", new
{
id = evt.ToolId,
result = resultContent,
isError = evt.ToolResultIsError
}, sseCt);
break;
}
}
},
modelId: modelId,
systemPrompt: systemText.Length > 0 ? systemText.ToString() : null,
workspace: workspace,
hasWorkspace: !string.IsNullOrEmpty(workspace),
planMode: planMode,
webSearch: webSearch,
mcpConfigPath: mcpConfigPath,
hasUnityMcp: !string.IsNullOrEmpty(unityMcpUrl),
effort: effort,
cancellationToken: workCt
);
// Bei CLI-Fehler → Fehlermeldung an Client senden und in DB speichern
if (!result.Success && !isUserChoice)
{
var errorMsg = !string.IsNullOrEmpty(result.Error) ? result.Error : "CLI hat keine Antwort geliefert";
// Error in DB speichern (damit er beim Neuladen sichtbar ist)
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "system",
Type = "error",
Content = $"CLI-Fehler: {errorMsg}"
});
await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {errorMsg}" }, sseCt);
}
// Antwort in DB speichern
if (fullText.Length > 0)
{
var finalText = fullText.ToString();
var msgId = await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "assistant",
Type = "text",
Content = finalText
});
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", finalText);
}
// Token-Usage vom CLI-Result übernehmen
usage.InputTokens = result.InputTokens;
usage.OutputTokens = result.OutputTokens;
usage.CacheReadTokens = result.CacheReadTokens;
usage.CacheCreationTokens = result.CacheCreationTokens;
var stopReason = isUserChoice ? "awaiting_user_choice" : (result.Success ? "end_turn" : "error");
await FinalizeChain(chain, usage, result.NumTurns > 0 ? result.NumTurns : 1, stopReason, sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
inputTokens = result.InputTokens,
outputTokens = result.OutputTokens,
cacheCreationTokens = result.CacheCreationTokens,
cacheReadTokens = result.CacheReadTokens,
costUsd = result.CostUsd,
iterations = result.NumTurns > 0 ? result.NumTurns : 1,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason,
viaCli = true
}, sseCt);
}
catch (OperationCanceledException) when (workCt.IsCancellationRequested)
{
// Stop-Button gedrückt → CLI-Prozess wurde gestoppt
// Wenn ask_user_choice aktiv war: als awaiting_user_choice markieren,
// damit die nächste Chain den Kontext (Frage + Optionen) behält.
var stoppedReason = isUserChoice ? "awaiting_user_choice" : "stopped";
await FinalizeChain(chain, usage, 1, stoppedReason, sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = stoppedReason,
viaCli = true
}, CancellationToken.None);
}
catch (OperationCanceledException)
{
// Client-Disconnect (sseCt gecancelled, aber NICHT workCt)
// CLI-Prozess läuft weiter im Hintergrund – hier nur Chain finalisieren
// (sollte nicht mehr passieren, da CLI jetzt workCt nutzt)
await FinalizeChain(chain, usage, 1, "client_disconnected", sw);
}
catch (Exception ex)
{
var errorMsg = $"CLI-Fehler: {ex.Message}";
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "system",
Type = "error",
Content = errorMsg
});
await SseHelper.TryWriteSse(response, "error", new { error = errorMsg }, sseCt);
await FinalizeChain(chain, usage, 1, "error", sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = "error",
viaCli = true
}, sseCt);
}
finally
{
// Temp-Bilddateien aufräumen
CleanupTempImages(tempImagePaths);
// MCP-Config Temp-Datei aufräumen
if (mcpConfigPath != null)
{
try { File.Delete(mcpConfigPath); } catch { }
}
}
}
/// <summary>
/// Non-Streaming Chat über die Claude CLI für Telegram (und andere Caller, die kein SSE brauchen).
/// Sammelt den finalen Text und gibt ihn zurück. Persistiert Text, Tool-Use und Tool-Result in der DB,
/// damit die History im Web-UI korrekt rekonstruiert werden kann.
/// </summary>
public async Task<string> ChatNonStreamingViaCli(
ChatContext ctx,
List<object> messages,
Func<int, Task> onIteration = null,
CancellationToken cancellationToken = default)
{
if (_cliService == null || !_cliService.IsAvailable)
return "";
var sw = System.Diagnostics.Stopwatch.StartNew();
var usage = new TokenUsage();
var tempImagePaths = new List<string>();
var chain = new ChainRecord
{
ChainId = ctx.ChainId,
SessionId = ctx.SessionId,
Model = ctx.ModelId
};
try
{
// System-Prompt als Text
var systemText = new StringBuilder(ctx.SystemPromptText ?? "");
// Auto-Context (analog Streaming-Pfad)
if ((ctx.Agent?.UseAutoContext ?? true) && messages.Count > 0)
{
try
{
var autoCtx = await BuildAutoContextAsync(_vectorSearch, messages, ctx.ChainId, ctx.AgentId);
if (!string.IsNullOrEmpty(autoCtx))
{
systemText.AppendLine(autoCtx);
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId,
Role = "system",
Type = "auto_context",
Content = autoCtx
});
}
}
catch { /* silent – Auto-Context optional */ }
}
// Prompt aus letzter User-Message + Bild-/PDF-Refs + History-Kontext
var prompt = ExtractLastUserText(messages) ?? "";
var imageRefs = ExtractLastUserImageRefs(messages, tempImagePaths);
if (!string.IsNullOrEmpty(imageRefs))
prompt = imageRefs + "\n" + prompt;
var historyContext = BuildHistoryContext(messages);
if (!string.IsNullOrEmpty(historyContext))
prompt = $"<conversation_history>\n{historyContext}\n</conversation_history>\n\n<current_request>\n{prompt}\n</current_request>";
var fullText = new StringBuilder();
if (onIteration != null) await onIteration(1);
var result = await _cliService.ChatStreamingAsync(
prompt,
async evt =>
{
switch (evt.Type)
{
case CliEventType.TextDelta:
fullText.Append(evt.Text);
break;
case CliEventType.ToolStart:
// Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB)
if (fullText.Length > 0)
{
var textForEmbed = fullText.ToString();
var preMsgId = await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId,
Iteration = 1,
Role = "assistant",
Type = "text",
Content = textForEmbed
});
_embeddingBg?.Enqueue(preMsgId, ctx.ChainId, ctx.SessionId, ctx.AgentId, "assistant", textForEmbed);
fullText.Clear();
}
if (onIteration != null) await onIteration(1);
break;
case CliEventType.ToolEnd:
{
var mappedName = MapCliToolName(evt.ToolName);
var mappedInput = MapCliToolInput(evt.ToolName, evt.ToolInput);
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId,
Iteration = 1,
Role = "assistant",
Type = "tool_use",
ToolName = mappedName,
ToolCallId = evt.ToolId,
Content = mappedInput
});
break;
}
case CliEventType.ToolResult:
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId,
Iteration = 1,
Role = "user",
Type = "tool_result",
ToolCallId = evt.ToolId,
Content = evt.ToolResultContent ?? "",
IsError = evt.ToolResultIsError
});
break;
}
},
modelId: ctx.ModelId,
systemPrompt: systemText.ToString(),
workspace: ctx.Workspace,
hasWorkspace: !string.IsNullOrEmpty(ctx.Workspace),
effort: ctx.Effort,
cancellationToken: cancellationToken
);
// Finalen Text persistieren
var finalText = fullText.ToString();
if (finalText.Length > 0)
{
var msgId = await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId,
Iteration = 1,
Role = "assistant",
Type = "text",
Content = finalText
});
_embeddingBg?.Enqueue(msgId, ctx.ChainId, ctx.SessionId, ctx.AgentId, "assistant", finalText);
}
usage.InputTokens = result.InputTokens;
usage.OutputTokens = result.OutputTokens;
usage.CacheReadTokens = result.CacheReadTokens;
usage.CacheCreationTokens = result.CacheCreationTokens;
var stopReason = result.Success ? "end_turn" : "error";
await FinalizeChain(chain, usage, result.NumTurns > 0 ? result.NumTurns : 1, stopReason, sw);
return finalText;
}
catch (Exception ex)
{
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId, Iteration = 1,
Role = "system", Type = "error",
Content = $"CLI-Fehler: {ex.Message}", IsError = true
});
await FinalizeChain(chain, usage, 1, "error", sw);
return "";
}
finally
{
CleanupTempImages(tempImagePaths);
}
}
/// <summary>
/// Mappt CLI-Tool-Namen (Edit, Write, Bash, etc.) auf gott-Tool-Namen (edit_file, write_file, etc.).
/// </summary>
private static string MapCliToolName(string cliName)
{
return cliName switch
{
"Edit" => "edit_file",
"Write" => "write_file",
"Read" => "read_file",
"Bash" => "execute",
"Glob" => "list_files",
"Grep" => "search_files",
"WebSearch" => "web_search",
"WebFetch" => "web_fetch",
"NotebookEdit" => "notebook_edit",
"TodoWrite" => "todo_write",
_ => cliName?.StartsWith("mcp__") == true ? cliName.Split("__").LastOrDefault() ?? cliName : cliName
};
}
/// <summary>
/// Mappt CLI-Tool-Input-Felder auf gott-Konventionen (z.B. file_path→path, old_string→old_text).
/// </summary>
private static string MapCliToolInput(string cliToolName, string inputJson)
{
if (string.IsNullOrEmpty(inputJson)) return inputJson;
try
{
var input = JsonSerializer.Deserialize<JsonElement>(inputJson);
if (input.ValueKind != JsonValueKind.Object) return inputJson;
switch (cliToolName)
{
case "Edit":
{
// file_path → path, old_string → old_text, new_string → new_text
var mapped = new Dictionary<string, object>();
foreach (var prop in input.EnumerateObject())
{
var key = prop.Name switch
{
"file_path" => "path",
"old_string" => "old_text",
"new_string" => "new_text",
_ => prop.Name
};
mapped[key] = prop.Value;
}
return JsonSerializer.Serialize(mapped);
}
case "Write":
{
// file_path → path
var mapped = new Dictionary<string, object>();
foreach (var prop in input.EnumerateObject())
{
var key = prop.Name == "file_path" ? "path" : prop.Name;
mapped[key] = prop.Value;
}
return JsonSerializer.Serialize(mapped);
}
case "Read":
{
// file_path → path
var mapped = new Dictionary<string, object>();
foreach (var prop in input.EnumerateObject())
{
var key = prop.Name == "file_path" ? "path" : prop.Name;
mapped[key] = prop.Value;
}
return JsonSerializer.Serialize(mapped);
}
case "Bash":
{
// command bleibt gleich
return inputJson;
}
default:
return inputJson;
}
}
catch
{
return inputJson;
}
}
/// <summary>
/// Baut einen Kontext-String aus der Konversations-History.
/// </summary>
private static string BuildHistoryContext(List<object> messages)
{
var sb = new StringBuilder();
// Letzte Message überspringen wenn es ein Text-Prompt ist (wird separat als current_request gesendet).
// Wenn die letzte Message ein tool_result ist (z.B. Choice-Antwort), NICHT überspringen.
int takeCount = messages.Count;
if (messages.Count > 0)
{
var lastJson = JsonSerializer.Serialize(messages[messages.Count - 1]);
var lastEl = JsonSerializer.Deserialize<JsonElement>(lastJson);
bool lastIsToolResult = false;
if (lastEl.TryGetProperty("content", out var lc) && lc.ValueKind == JsonValueKind.Array)
{
foreach (var b in lc.EnumerateArray())
{
var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : "";
if (bType == "tool_result") { lastIsToolResult = true; break; }
}
}
if (!lastIsToolResult)
takeCount = messages.Count - 1;
}
foreach (var msg in messages.Take(takeCount))
{
var json = JsonSerializer.Serialize(msg);
var el = JsonSerializer.Deserialize<JsonElement>(json);
if (!el.TryGetProperty("role", out var role)) continue;
var roleStr = role.GetString() ?? "";
if (el.TryGetProperty("content", out var content))
{
if (content.ValueKind == JsonValueKind.String)
{
sb.AppendLine($"[{roleStr}]: {content.GetString()}");
}
else if (content.ValueKind == JsonValueKind.Array)
{
foreach (var block in content.EnumerateArray())
{
var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : "";
if (blockType == "text" && block.TryGetProperty("text", out var txt))
{
sb.AppendLine($"[{roleStr}]: {txt.GetString()}");
}
else if (blockType == "tool_use" && block.TryGetProperty("name", out var tn) && tn.GetString() == "ask_user_choice")
{
if (block.TryGetProperty("input", out var inp))
{
var q = inp.TryGetProperty("question", out var qq) ? qq.GetString() ?? "" : "";
var opts = new List<string>();
if (inp.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
foreach (var o in cc.EnumerateArray()) opts.Add(o.GetString() ?? "");
sb.AppendLine($"[assistant]: Ich habe gefragt: \"{q}\" mit Optionen: {string.Join(", ", opts)}");
}
}
else if (blockType == "tool_result" && block.TryGetProperty("content", out var trContent))
{
sb.AppendLine($"[user]: Du hast gewählt: \"{trContent.GetString()}\"");
}
}
}
}
}
return sb.ToString().Trim();
}
/// <summary>
/// Sucht einen verfügbaren Python-Befehl (python3, python, py).
/// Gibt null zurück, wenn keiner gefunden wird.
/// </summary>
private static string FindPythonCommand()
{
if (_pythonCmdChecked) return _cachedPythonCmd;
foreach (var cmd in new[] { "python3", "python", "py" })
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = cmd,
Arguments = "--version",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = System.Diagnostics.Process.Start(psi);
if (proc != null)
{
proc.WaitForExit(3000);
if (proc.ExitCode == 0)
{
_cachedPythonCmd = cmd;
_pythonCmdChecked = true;
Console.WriteLine($"[MCP] Python gefunden: {cmd}");
return cmd;
}
}
}
catch { /* Command not found – try next */ }
}
_pythonCmdChecked = true;
_cachedPythonCmd = null;
return null;
}
/// <summary>
/// Generiert eine temporäre MCP-Config-Datei für die Claude CLI.
/// Enthält den MCP-Server für remote_execute.
/// </summary>
private string GenerateMcpConfig(string sessionId, string agentId = null, bool hasRemote = false, string unityMcpUrl = null, string unityMcpToken = null, string remoteOs = null)
{
// Port aus Konfiguration oder Default
var port = 19000;
var scriptPath = Path.Combine(AppContext.BaseDirectory, "scripts", "mcp-remote-execute.py");
// Fallback: Quell-Verzeichnis prüfen (für Dev-Modus)
if (!File.Exists(scriptPath))
{
var projectRoot = Path.GetDirectoryName(AppPaths.WwwRoot) ?? AppContext.BaseDirectory;
var srcPath = Path.Combine(projectRoot, "scripts", "mcp-remote-execute.py");
if (File.Exists(srcPath)) scriptPath = srcPath;
}
var env = new Dictionary<string, string>
{
["GOTT_API_URL"] = $"http://localhost:{port}",
["GOTT_SESSION_ID"] = sessionId ?? "",
["GOTT_AUTH_TOKEN"] = Endpoints.InternalEndpoint.InternalToken,
["GOTT_AGENT_ID"] = agentId ?? "",
["GOTT_HAS_REMOTE"] = hasRemote ? "true" : "false",
["GOTT_REMOTE_OS"] = remoteOs ?? ""
};
var pythonCmd = FindPythonCommand();
var mcpServers = new Dictionary<string, object>
{
["gott-remote"] = new
{
command = pythonCmd ?? "python3",
args = new[] { scriptPath },
env
}
};
if (pythonCmd == null)
Console.WriteLine("[MCP] ⚠ Python nicht gefunden (python3/python/py) – MCP-Tools werden nicht funktionieren!");
// Unity MCP Server (pro Session konfigurierbar)
// Claude CLI erfordert expliziten "type" – "http" für streamableHttp, "sse" für SSE
if (!string.IsNullOrEmpty(unityMcpUrl))
{
// URL-basierte Typ-Erkennung: /sse → SSE-Transport, sonst HTTP (streamableHttp)
var mcpType = unityMcpUrl.TrimEnd('/').EndsWith("/sse", StringComparison.OrdinalIgnoreCase) ? "sse" : "http";
var unityConfig = new Dictionary<string, object>
{
["type"] = mcpType,
["url"] = unityMcpUrl
};
if (!string.IsNullOrEmpty(unityMcpToken))
{
unityConfig["headers"] = new Dictionary<string, string>
{
["Authorization"] = $"Bearer {unityMcpToken}"
};
}
mcpServers["unity"] = unityConfig;
}
var config = new { mcpServers };
var json = System.Text.Json.JsonSerializer.Serialize(config, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
var tempPath = Path.Combine(Path.GetTempPath(), $"gott-mcp-{sessionId}.json");
File.WriteAllText(tempPath, json);
return tempPath;
}
}