feat: MCP-Server für remote_execute + Inline-Choice-UI
- Python MCP-Server (stdio, JSON-RPC 2.0) für remote_execute via Claude CLI - Interner API-Endpoint /api/internal/remote-execute mit Token-Auth - MCP-Config wird automatisch generiert wenn Remote-Verbindung vorhanden - Auth-Middleware: /api/internal/ von Auth ausgenommen - Frontend: :::ASK_USER_CHOICE::: Blöcke als interaktive Buttons rendern - Streaming: Unvollständige Choice-Blöcke werden während des Streams versteckt - tool-ui.js: MCP-Tool-Namen generisch normalisieren (mcp__server__tool → tool) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
ee0b817f25
commit
3b068a7866
14 files changed
+576
-6
No files matched your search
@@ -245,7 +245,7 @@ public static class ChatEndpoint
|
|||||||
{
|
{
|
||||||
if (anthropic.IsCliAvailable)
|
if (anthropic.IsCliAvailable)
|
||||||
{
|
{
|
||||||
await anthropic.StreamChatViaCli(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, workspace: ctx.Workspace, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: body.PlanMode, webSearch: !string.IsNullOrEmpty(ctx.WebSearchVersion), sessionId: sessionId, workCt: workCt, sseCt: sseCt);
|
await anthropic.StreamChatViaCli(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, workspace: ctx.Workspace, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: body.PlanMode, webSearch: !string.IsNullOrEmpty(ctx.WebSearchVersion), sessionId: sessionId, remoteMgr: remoteMgr, workCt: workCt, sseCt: sseCt);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Gott.Services;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
|
namespace Gott.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interne API-Endpoints für MCP-Server und andere lokale Integrationen.
|
||||||
|
/// Gesichert über einen internen Token (da Reverse-Proxy den Localhost-Check umgeht).
|
||||||
|
/// </summary>
|
||||||
|
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 void MapInternalEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapPost("/api/internal/remote-execute", HandleRemoteExecute);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> HandleRemoteExecute(HttpContext ctx, RemoteConnectionManager mgr)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Token-Check: Header "X-Internal-Token" muss den internen Token enthalten
|
||||||
|
var token = ctx.Request.Headers["X-Internal-Token"].FirstOrDefault() ?? "";
|
||||||
|
if (token != InternalToken)
|
||||||
|
return Results.StatusCode(403);
|
||||||
|
|
||||||
|
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 command = json.TryGetProperty("command", out var cmd) ? cmd.GetString() ?? "" : "";
|
||||||
|
var sessionId = json.TryGetProperty("sessionId", out var sid) ? sid.GetString() ?? "" : "";
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(command))
|
||||||
|
return Results.BadRequest(new { error = "command erforderlich" });
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(sessionId))
|
||||||
|
return Results.BadRequest(new { error = "sessionId erforderlich" });
|
||||||
|
|
||||||
|
var conn = mgr.GetBySessionId(sessionId);
|
||||||
|
if (conn == null)
|
||||||
|
return Results.NotFound(new { error = "Keine Remote-Verbindung für diese Session" });
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource(System.TimeSpan.FromSeconds(180));
|
||||||
|
var result = await mgr.ExecuteCommand(conn.Id, command, 180, cts.Token);
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
exitCode = result.ExitCode,
|
||||||
|
stdout = result.Output ?? "",
|
||||||
|
stderr = result.Error ?? ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
return Results.Json(new { error = ex.Message, stackTrace = ex.StackTrace }, statusCode: 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,8 @@ public class AuthMiddleware
|
|||||||
var isImageFile = path.StartsWith("/api/images/file/") && ctx.Request.Method == "GET";
|
var isImageFile = path.StartsWith("/api/images/file/") && ctx.Request.Method == "GET";
|
||||||
var isRemoteWs = path == "/api/remote";
|
var isRemoteWs = path == "/api/remote";
|
||||||
var isRemoteScript = path == "/remote.ps1";
|
var isRemoteScript = path == "/remote.ps1";
|
||||||
if (!path.StartsWith("/api/") || path == "/api/auth/login" || isTelegramWebhook || isSharedGet || isAutologin || isImageFile || isRemoteWs || isRemoteScript)
|
var isInternal = path.StartsWith("/api/internal/");
|
||||||
|
if (!path.StartsWith("/api/") || path == "/api/auth/login" || isTelegramWebhook || isSharedGet || isAutologin || isImageFile || isRemoteWs || isRemoteScript || isInternal)
|
||||||
{
|
{
|
||||||
await _next(ctx);
|
await _next(ctx);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Plan: MCP-Server für `remote_execute` in der CLI
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Ein eigener MCP-Server (Model Context Protocol, stdio-basiert) der `remote_execute` als Tool für die Claude Code CLI exposed. Die CLI erkennt MCP-Tools nativ und kann sie wie Built-in-Tools verwenden.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude CLI ←→ MCP-Server (stdio) ←→ gott HTTP-API ←→ RemoteEndpoint → SSH/Exec
|
||||||
|
```
|
||||||
|
|
||||||
|
## Komponenten
|
||||||
|
|
||||||
|
### 1. MCP-Server: `scripts/mcp-server` (oder `Services/McpServer.cs`)
|
||||||
|
|
||||||
|
**Variante A: Standalone Script (Python/Node/Bash+jq)**
|
||||||
|
- Leichtgewichtig, unabhängig von .NET Runtime
|
||||||
|
- Kommuniziert über stdin/stdout (JSON-RPC 2.0)
|
||||||
|
- Ruft gott's interne HTTP-API auf
|
||||||
|
|
||||||
|
**Variante B: .NET Console App**
|
||||||
|
- Kann ToolService/RemoteService direkt referenzieren
|
||||||
|
- Gleiche Codebase, keine API-Zwischenschicht nötig
|
||||||
|
- Aber: Eigener Prozess, muss separat gebaut werden
|
||||||
|
|
||||||
|
**Empfehlung: Variante A** – ein einfaches Script das die gott-API aufruft. Minimaler Overhead, keine Build-Abhängigkeit.
|
||||||
|
|
||||||
|
### 2. Interner API-Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/internal/remote-execute
|
||||||
|
Authorization: (interner Token oder localhost-only)
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"command": "docker ps",
|
||||||
|
"sessionId": "abc123", // für Zuordnung zur Chat-Session
|
||||||
|
"serverId": "optional" // falls mehrere Server
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exitCode": 0,
|
||||||
|
"stdout": "CONTAINER ID IMAGE ...",
|
||||||
|
"stderr": "",
|
||||||
|
"durationMs": 234
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. MCP-Server Konfiguration
|
||||||
|
|
||||||
|
In `.claude/settings.json` oder per `--mcp-config`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"gott-remote": {
|
||||||
|
"command": "/app/gott/scripts/mcp-remote-execute",
|
||||||
|
"type": "stdio",
|
||||||
|
"env": {
|
||||||
|
"GOTT_API_URL": "http://localhost:5000",
|
||||||
|
"GOTT_SESSION_ID": "${SESSION_ID}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. MCP-Protokoll (stdio, JSON-RPC 2.0)
|
||||||
|
|
||||||
|
**Tool-Definition (tools/list Response):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "remote_execute",
|
||||||
|
"description": "Führt einen Befehl auf dem Remote-Server aus (via SSH oder lokal)",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Der auszuführende Befehl"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["command"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tool-Call (tools/call):**
|
||||||
|
- MCP-Server empfängt JSON-RPC Request
|
||||||
|
- Ruft `POST /api/internal/remote-execute` auf
|
||||||
|
- Gibt stdout/stderr als Text-Content zurück
|
||||||
|
|
||||||
|
### 5. Integration in ClaudeCliService
|
||||||
|
|
||||||
|
- MCP-Server-Config wird beim CLI-Start übergeben
|
||||||
|
- `--mcp-config /tmp/gott-mcp-{sessionId}.json` oder globale Config
|
||||||
|
- SessionId/ServerId werden als Environment-Variablen an den MCP-Server übergeben
|
||||||
|
|
||||||
|
### 6. AnthropicService: CLI-Aufruf anpassen
|
||||||
|
|
||||||
|
In `StreamChatViaCli` den MCP-Config-Pfad generieren und an `ChatStreamingAsync` übergeben:
|
||||||
|
```csharp
|
||||||
|
var mcpConfig = GenerateMcpConfig(sessionId, serverId);
|
||||||
|
var mcpConfigPath = WriteTempMcpConfig(mcpConfig);
|
||||||
|
// CLI-Aufruf mit --mcp-config mcpConfigPath
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
|
||||||
|
| Datei | Aktion | Beschreibung |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| `scripts/mcp-remote-execute` | **NEU** | MCP-Server Script (stdio) |
|
||||||
|
| `Endpoints/InternalEndpoint.cs` | **NEU** | Interner API-Endpoint für remote-execute |
|
||||||
|
| `Services/ClaudeCliService.cs` | **EDIT** | MCP-Config übergeben |
|
||||||
|
| `Services/AnthropicService.cs` | **EDIT** | MCP-Config generieren |
|
||||||
|
| `.claude/settings.json` | **EDIT** | MCP-Server registrieren (oder dynamisch per --mcp-config) |
|
||||||
|
|
||||||
|
## Offene Fragen
|
||||||
|
|
||||||
|
1. **Script-Sprache**: Python (weit verbreitet), Node (npm-Ökosystem), oder Shell+curl+jq (keine Runtime-Abhängigkeit)?
|
||||||
|
2. **Auth für internen Endpoint**: Nur localhost erlauben? Oder interner Token?
|
||||||
|
3. **Server-Auswahl**: Wenn mehrere Remote-Server konfiguriert sind – soll der MCP-Server alle als separate Tools exposen oder einen Default verwenden?
|
||||||
|
4. **Tool-Ergebnis-Mapping**: Wie sollen remote_execute-Results in der gott-UI angezeigt werden? Als normales Tool-Result oder speziell formatiert?
|
||||||
@@ -179,6 +179,7 @@ app.MapApiKeysEndpoints();
|
|||||||
app.MapOpenClawEndpoints();
|
app.MapOpenClawEndpoints();
|
||||||
app.MapRemoteEndpoints();
|
app.MapRemoteEndpoints();
|
||||||
app.MapDatabaseExplorerEndpoints();
|
app.MapDatabaseExplorerEndpoints();
|
||||||
|
app.MapInternalEndpoints();
|
||||||
|
|
||||||
var port = app.Configuration.GetValue<int>("Port", 19000);
|
var port = app.Configuration.GetValue<int>("Port", 19000);
|
||||||
app.Run($"http://0.0.0.0:{port}");
|
app.Run($"http://0.0.0.0:{port}");
|
||||||
@@ -1733,6 +1733,49 @@ public class AnthropicService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
// 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))
|
||||||
|
{
|
||||||
|
// wwwroot ist typischerweise /app/gott/wwwroot → eine Ebene hoch = Projekt-Root
|
||||||
|
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 config = new
|
||||||
|
{
|
||||||
|
mcpServers = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["gott-remote"] = new
|
||||||
|
{
|
||||||
|
command = "python3",
|
||||||
|
args = new[] { scriptPath },
|
||||||
|
env = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["GOTT_API_URL"] = $"http://localhost:{port}",
|
||||||
|
["GOTT_SESSION_ID"] = sessionId,
|
||||||
|
["GOTT_AUTH_TOKEN"] = Endpoints.InternalEndpoint.InternalToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extrahiert Web-Search-Sources aus einem web_search_tool_result-Block.
|
/// Extrahiert Web-Search-Sources aus einem web_search_tool_result-Block.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1886,6 +1929,7 @@ public class AnthropicService
|
|||||||
bool planMode = false,
|
bool planMode = false,
|
||||||
bool webSearch = false,
|
bool webSearch = false,
|
||||||
string sessionId = null,
|
string sessionId = null,
|
||||||
|
RemoteConnectionManager remoteMgr = null,
|
||||||
CancellationToken workCt = default,
|
CancellationToken workCt = default,
|
||||||
CancellationToken sseCt = default)
|
CancellationToken sseCt = default)
|
||||||
{
|
{
|
||||||
@@ -1900,6 +1944,7 @@ public class AnthropicService
|
|||||||
var usage = new TokenUsage();
|
var usage = new TokenUsage();
|
||||||
var fullText = new StringBuilder();
|
var fullText = new StringBuilder();
|
||||||
var tempImagePaths = new List<string>();
|
var tempImagePaths = new List<string>();
|
||||||
|
string mcpConfigPath = null;
|
||||||
|
|
||||||
var chain = new ChainRecord
|
var chain = new ChainRecord
|
||||||
{
|
{
|
||||||
@@ -1910,6 +1955,12 @@ public class AnthropicService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// MCP-Config für remote_execute generieren (wenn Remote-Verbindung vorhanden)
|
||||||
|
if (remoteMgr != null && !string.IsNullOrEmpty(sessionId) && remoteMgr.GetBySessionId(sessionId) != null)
|
||||||
|
{
|
||||||
|
mcpConfigPath = GenerateMcpConfig(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
// System-Prompt als Text extrahieren
|
// System-Prompt als Text extrahieren
|
||||||
var systemText = new StringBuilder();
|
var systemText = new StringBuilder();
|
||||||
if (systemPrompt != null)
|
if (systemPrompt != null)
|
||||||
@@ -2079,6 +2130,7 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En
|
|||||||
hasWorkspace: !string.IsNullOrEmpty(workspace),
|
hasWorkspace: !string.IsNullOrEmpty(workspace),
|
||||||
planMode: planMode,
|
planMode: planMode,
|
||||||
webSearch: webSearch,
|
webSearch: webSearch,
|
||||||
|
mcpConfigPath: mcpConfigPath,
|
||||||
cancellationToken: workCt
|
cancellationToken: workCt
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2181,6 +2233,12 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En
|
|||||||
{
|
{
|
||||||
// Temp-Bilddateien aufräumen
|
// Temp-Bilddateien aufräumen
|
||||||
CleanupTempImages(tempImagePaths);
|
CleanupTempImages(tempImagePaths);
|
||||||
|
|
||||||
|
// MCP-Config Temp-Datei aufräumen
|
||||||
|
if (mcpConfigPath != null)
|
||||||
|
{
|
||||||
|
try { File.Delete(mcpConfigPath); } catch { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2200,7 +2258,8 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En
|
|||||||
"WebSearch" => "web_search",
|
"WebSearch" => "web_search",
|
||||||
"WebFetch" => "web_fetch",
|
"WebFetch" => "web_fetch",
|
||||||
"NotebookEdit" => "notebook_edit",
|
"NotebookEdit" => "notebook_edit",
|
||||||
_ => cliName
|
"mcp__gott-remote__remote_execute" => "remote_execute",
|
||||||
|
_ => cliName?.StartsWith("mcp__") == true ? cliName.Split("__").LastOrDefault() ?? cliName : cliName
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ public class ClaudeCliService
|
|||||||
bool hasWorkspace = false,
|
bool hasWorkspace = false,
|
||||||
bool planMode = false,
|
bool planMode = false,
|
||||||
bool webSearch = false,
|
bool webSearch = false,
|
||||||
|
string mcpConfigPath = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!_isAvailable)
|
if (!_isAvailable)
|
||||||
@@ -111,6 +112,8 @@ public class ClaudeCliService
|
|||||||
tools.AddRange(new[] { "Bash(*)", "Read", "Write", "Edit", "Glob", "Grep" });
|
tools.AddRange(new[] { "Bash(*)", "Read", "Write", "Edit", "Glob", "Grep" });
|
||||||
}
|
}
|
||||||
if (webSearch) { tools.Add("WebFetch"); tools.Add("WebSearch"); }
|
if (webSearch) { tools.Add("WebFetch"); tools.Add("WebSearch"); }
|
||||||
|
// MCP-Tools erlauben (wenn MCP-Config vorhanden)
|
||||||
|
if (!string.IsNullOrEmpty(mcpConfigPath)) { tools.Add("mcp__gott-remote__remote_execute"); }
|
||||||
args.Add("--allowed-tools");
|
args.Add("--allowed-tools");
|
||||||
args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\"");
|
args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\"");
|
||||||
|
|
||||||
@@ -132,6 +135,12 @@ public class ClaudeCliService
|
|||||||
args.Add(systemPrompt);
|
args.Add(systemPrompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(mcpConfigPath))
|
||||||
|
{
|
||||||
|
args.Add("--mcp-config");
|
||||||
|
args.Add(mcpConfigPath);
|
||||||
|
}
|
||||||
|
|
||||||
return await RunCliStreamingAsync(args, prompt, onEvent, cancellationToken);
|
return await RunCliStreamingAsync(args, prompt, onEvent, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,10 @@
|
|||||||
<Content Include="Templates/**" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
<Content Include="Templates/**" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="scripts/**" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Cronos" Version="0.11.1" />
|
<PackageReference Include="Cronos" Version="0.11.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.3" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.3" />
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
MCP-Server (Model Context Protocol) für remote_execute.
|
||||||
|
Kommuniziert über stdin/stdout (JSON-RPC 2.0) mit der Claude CLI.
|
||||||
|
Ruft die gott-interne HTTP-API auf um Befehle auf dem Remote-PC auszuführen.
|
||||||
|
|
||||||
|
Umgebungsvariablen:
|
||||||
|
GOTT_API_URL - URL der gott-API (z.B. http://localhost:19000)
|
||||||
|
GOTT_SESSION_ID - Session-ID für die Remote-Verbindungszuordnung
|
||||||
|
GOTT_AUTH_TOKEN - Auth-Token für die gott-API (optional, für nicht-localhost)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import os
|
||||||
|
|
||||||
|
API_URL = os.environ.get("GOTT_API_URL", "http://localhost:19000")
|
||||||
|
SESSION_ID = os.environ.get("GOTT_SESSION_ID", "")
|
||||||
|
AUTH_TOKEN = os.environ.get("GOTT_AUTH_TOKEN", "")
|
||||||
|
|
||||||
|
TOOL_NAME = "remote_execute"
|
||||||
|
TOOL_DESCRIPTION = (
|
||||||
|
"Führt einen PowerShell-Befehl auf der verbundenen Remote-Windows-Workstation aus. "
|
||||||
|
"Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs "
|
||||||
|
"(z.B. Druckerprobleme, Netzwerk, Services, Registry). Timeout: 180 Sekunden."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_response(response):
|
||||||
|
"""Sendet eine JSON-RPC Response über stdout."""
|
||||||
|
data = json.dumps(response)
|
||||||
|
sys.stdout.write(data + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_initialize(req_id, params):
|
||||||
|
"""MCP initialize handshake."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {
|
||||||
|
"tools": {}
|
||||||
|
},
|
||||||
|
"serverInfo": {
|
||||||
|
"name": "gott-remote",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_tools_list(req_id):
|
||||||
|
"""Gibt die verfügbaren Tools zurück."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": {
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": TOOL_NAME,
|
||||||
|
"description": TOOL_DESCRIPTION,
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "PowerShell-Befehl zum Ausführen auf dem Remote-PC"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["command"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_tools_call(req_id, params):
|
||||||
|
"""Führt ein Tool aus."""
|
||||||
|
tool_name = params.get("name", "")
|
||||||
|
arguments = params.get("arguments", {})
|
||||||
|
|
||||||
|
if tool_name != TOOL_NAME:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": {
|
||||||
|
"content": [{"type": "text", "text": f"Unbekanntes Tool: {tool_name}"}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
command = arguments.get("command", "")
|
||||||
|
if not command:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": {
|
||||||
|
"content": [{"type": "text", "text": "Fehler: Kein Befehl angegeben"}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# API-Call an gott
|
||||||
|
try:
|
||||||
|
url = f"{API_URL}/api/internal/remote-execute"
|
||||||
|
payload = json.dumps({
|
||||||
|
"command": command,
|
||||||
|
"sessionId": SESSION_ID
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, data=payload, method="POST")
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
if AUTH_TOKEN:
|
||||||
|
req.add_header("X-Internal-Token", AUTH_TOKEN)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=200) as resp:
|
||||||
|
result = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
# Ergebnis formatieren
|
||||||
|
output_parts = []
|
||||||
|
if result.get("stdout"):
|
||||||
|
output_parts.append(result["stdout"])
|
||||||
|
if result.get("stderr"):
|
||||||
|
output_parts.append(f"[stderr] {result['stderr']}")
|
||||||
|
if result.get("exitCode", 0) != 0:
|
||||||
|
output_parts.append(f"[Exit Code: {result['exitCode']}]")
|
||||||
|
|
||||||
|
text = "\n".join(output_parts) if output_parts else "(keine Ausgabe)"
|
||||||
|
is_error = result.get("exitCode", 0) != 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": {
|
||||||
|
"content": [{"type": "text", "text": text}],
|
||||||
|
"isError": is_error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = ""
|
||||||
|
try:
|
||||||
|
body = e.read().decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": {
|
||||||
|
"content": [{"type": "text", "text": f"API-Fehler {e.code}: {body}"}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": {
|
||||||
|
"content": [{"type": "text", "text": f"Fehler: {str(e)}"}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_notifications_initialized(params):
|
||||||
|
"""Handle notifications/initialized - no response needed."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Hauptloop: Liest JSON-RPC Requests von stdin, verarbeitet sie."""
|
||||||
|
for line in sys.stdin:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
request = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
method = request.get("method", "")
|
||||||
|
req_id = request.get("id")
|
||||||
|
params = request.get("params", {})
|
||||||
|
|
||||||
|
# Notifications (kein id) brauchen keine Response
|
||||||
|
if req_id is None:
|
||||||
|
if method == "notifications/initialized":
|
||||||
|
handle_notifications_initialized(params)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if method == "initialize":
|
||||||
|
response = handle_initialize(req_id, params)
|
||||||
|
elif method == "tools/list":
|
||||||
|
response = handle_tools_list(req_id)
|
||||||
|
elif method == "tools/call":
|
||||||
|
response = handle_tools_call(req_id, params)
|
||||||
|
else:
|
||||||
|
response = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"error": {
|
||||||
|
"code": -32601,
|
||||||
|
"message": f"Method not found: {method}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1280,6 +1280,14 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-choice-block {
|
||||||
|
margin: var(--gap) 0;
|
||||||
|
padding: var(--gap);
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
background: var(--f1-bg);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── OpenClaw Import Badge ── */
|
/* ── OpenClaw Import Badge ── */
|
||||||
|
|
||||||
.openclaw-badge {
|
.openclaw-badge {
|
||||||
|
|||||||
@@ -402,6 +402,14 @@ async function sendMessage() {
|
|||||||
await _runStream((signal) => apiChat(content, getCurrentSessionId(), signal, _planModeActive));
|
await _runStream((signal) => apiChat(content, getCurrentSessionId(), signal, _planModeActive));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Direct Message (für Inline-Choice-Buttons) ──
|
||||||
|
async function sendMessageDirect(text) {
|
||||||
|
if (isStreaming || !text) return;
|
||||||
|
addUserBubble(text, true);
|
||||||
|
scrollDownForce();
|
||||||
|
await _runStream((signal) => apiChat(text, getCurrentSessionId(), signal, _planModeActive));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Choice Click Handler ──
|
// ── Choice Click Handler ──
|
||||||
|
|
||||||
async function handleChoiceClick(toolCallId, choice, bubbleDiv) {
|
async function handleChoiceClick(toolCallId, choice, bubbleDiv) {
|
||||||
@@ -419,3 +427,23 @@ async function handleChoiceClick(toolCallId, choice, bubbleDiv) {
|
|||||||
|
|
||||||
await _runStream((signal) => apiChatChoice(getCurrentSessionId(), toolCallId, choice, signal, _planModeActive));
|
await _runStream((signal) => apiChatChoice(getCurrentSessionId(), toolCallId, choice, signal, _planModeActive));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inline Choice Blocks (Fallback aus Markdown-Rendering) ──
|
||||||
|
// Event-Delegation für .inline-choice-btn Buttons die von renderMarkdown() erzeugt wurden
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest('.inline-choice-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
var block = btn.closest('.inline-choice-block');
|
||||||
|
if (!block || block.classList.contains('choice-answered')) return;
|
||||||
|
|
||||||
|
var choice = btn.dataset.choice;
|
||||||
|
// Buttons deaktivieren + markieren
|
||||||
|
block.querySelectorAll('.inline-choice-btn').forEach(function(b) {
|
||||||
|
b.disabled = true;
|
||||||
|
b.classList.add(b === btn ? 'choice-selected' : 'choice-unselected');
|
||||||
|
});
|
||||||
|
block.classList.add('choice-answered');
|
||||||
|
|
||||||
|
// Inline-Choice als normale User-Nachricht senden (kein tool_use in DB vorhanden)
|
||||||
|
sendMessageDirect(choice);
|
||||||
|
});
|
||||||
@@ -6,6 +6,18 @@ function renderMarkdown(text) {
|
|||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
var src = text.trim();
|
var src = text.trim();
|
||||||
|
|
||||||
|
// 0) :::ASK_USER_CHOICE::: Blöcke erkennen und als Platzhalter speichern (vor Markdown-Parsing)
|
||||||
|
var choiceBlocks = [];
|
||||||
|
src = src.replace(/:::ASK_USER_CHOICE:::\s*\n?([\s\S]*?)\n?\s*:::END_ASK_USER_CHOICE:::/g, function(match, jsonStr) {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(jsonStr.trim());
|
||||||
|
choiceBlocks.push(data);
|
||||||
|
return '%%CHOICE' + (choiceBlocks.length - 1) + '%%';
|
||||||
|
} catch(e) {
|
||||||
|
return match; // JSON-Fehler → als Text belassen
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 1) LaTeX-Ausdrücke VOR dem Markdown-Parsing schützen
|
// 1) LaTeX-Ausdrücke VOR dem Markdown-Parsing schützen
|
||||||
// Reihenfolge: zuerst Block ($$...$$), dann Inline ($...$)
|
// Reihenfolge: zuerst Block ($$...$$), dann Inline ($...$)
|
||||||
var mathBlocks = [];
|
var mathBlocks = [];
|
||||||
@@ -95,6 +107,22 @@ function renderMarkdown(text) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 8) Choice-Platzhalter durch interaktive Buttons ersetzen
|
||||||
|
html = html.replace(/%%CHOICE(\d+)%%/g, function(m, idx) {
|
||||||
|
var data = choiceBlocks[parseInt(idx)];
|
||||||
|
var q = data.question || '';
|
||||||
|
var choices = data.choices || [];
|
||||||
|
var jsonAttr = esc(JSON.stringify(data));
|
||||||
|
var out = '<div class="inline-choice-block" data-choice-json="' + jsonAttr + '">';
|
||||||
|
out += '<div class="choice-question">' + esc(q) + '</div>';
|
||||||
|
out += '<div class="choice-buttons">';
|
||||||
|
for (var i = 0; i < choices.length; i++) {
|
||||||
|
out += '<button class="choice-btn inline-choice-btn" data-choice="' + esc(choices[i]) + '">' + esc(choices[i]) + '</button>';
|
||||||
|
}
|
||||||
|
out += '</div></div>';
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -15,7 +15,16 @@ function startStreamBubble() {
|
|||||||
function appendToStream(text) {
|
function appendToStream(text) {
|
||||||
if (!_streamDiv) startStreamBubble();
|
if (!_streamDiv) startStreamBubble();
|
||||||
_streamText += text;
|
_streamText += text;
|
||||||
_streamDiv.innerHTML = renderMarkdown(_streamText);
|
|
||||||
|
// Wenn ein :::ASK_USER_CHOICE::: Block begonnen aber noch nicht abgeschlossen wurde,
|
||||||
|
// nur den Text VOR dem Marker rendern (Marker-Block verstecken bis er komplett ist)
|
||||||
|
var displayText = _streamText;
|
||||||
|
var choiceStart = displayText.indexOf(':::ASK_USER_CHOICE:::');
|
||||||
|
if (choiceStart >= 0 && displayText.indexOf(':::END_ASK_USER_CHOICE:::') < 0) {
|
||||||
|
displayText = displayText.substring(0, choiceStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
_streamDiv.innerHTML = renderMarkdown(displayText);
|
||||||
// Cursor ins letzte Inline/Text-Element einfügen, damit er nicht auf eine neue Zeile rutscht
|
// Cursor ins letzte Inline/Text-Element einfügen, damit er nicht auf eine neue Zeile rutscht
|
||||||
const cursor = document.createElement('span');
|
const cursor = document.createElement('span');
|
||||||
cursor.className = 'cursor';
|
cursor.className = 'cursor';
|
||||||
|
|||||||
@@ -10,12 +10,19 @@ const CLI_TOOL_NAME_MAP = {
|
|||||||
'WebSearch': 'web_search',
|
'WebSearch': 'web_search',
|
||||||
'WebFetch': 'web_fetch',
|
'WebFetch': 'web_fetch',
|
||||||
'NotebookEdit': 'notebook_edit',
|
'NotebookEdit': 'notebook_edit',
|
||||||
'ToolSearch': 'tool_search'
|
'ToolSearch': 'tool_search',
|
||||||
|
'mcp__gott-remote__remote_execute': 'remote_execute'
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Normalize CLI tool name to gott convention (Edit → edit_file, etc.) */
|
/** Normalize CLI tool name to gott convention (Edit → edit_file, etc.) */
|
||||||
function normalizeToolName(name) {
|
function normalizeToolName(name) {
|
||||||
return CLI_TOOL_NAME_MAP[name] || name;
|
if (CLI_TOOL_NAME_MAP[name]) return CLI_TOOL_NAME_MAP[name];
|
||||||
|
// Generisch: mcp__server__tool → tool
|
||||||
|
if (name && name.startsWith('mcp__')) {
|
||||||
|
var parts = name.split('__');
|
||||||
|
return parts[parts.length - 1] || name;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize CLI tool input fields (file_path → path, old_string → old_text, etc.) */
|
/** Normalize CLI tool input fields (file_path → path, old_string → old_text, etc.) */
|
||||||
|
|||||||
Reference in new issue
Block a user