Supervisor S-3/S-4: MCP-Light-Server, Profile, gespeicherte Berichte, Designer-UI

S-4 MCP-Light (Daten-Tuer fuer externe KI-Clients, KEIN Modell-Zugang - Modelle laufen
weiter ueber OpenRouter):
- McpJsonRpc (pur): JSON-RPC 2.0 fuer initialize/ping/tools/list/tools/call ueber die
  read-only Tool-Registry; Notifications/Fehlerfaelle spezifikationskonform.
- McpLightServer (HostedService): lokaler Streamable-HTTP-Endpoint. OPT-IN via
  POLYTRADER_MCP_PORT, bindet NUR 127.0.0.1. Claude Code:
  claude mcp add --transport http polytrader http://127.0.0.1:PORT/mcp
- End-to-End-Test ueber echtes HTTP (initialize, tools/call, Notification=202, GET=405).

S-3 Profile + Berichte:
- SupervisorProfiles: Allgemein/Technik/CopyTrading/ResolutionFarming als System-Prompt-
  Zusatz + Tool-Subset ueber EINER Agent-Infrastruktur (Technik z.B. ohne Strategie-Tools).
  Agent filtert Tools je Profil.
- sup_reports (SupervisorDbContext, Migration generiert UND angewendet): jede Analyse wird
  mit Profil/Modell/Frage/Antwort/Tool-Aufrufen/Token gespeichert -> Supervisor auditierbar.

UI (Richards Vorgabe: designerfaehig):
- SupervisorMainForm auf partial + .Designer.cs umgestellt - alle Controls im Designer
  (3 Tabs: Analyse mit Profil-Combo+Modellfeld, Dossiers, Berichte mit Split/Grid/Detail).

Tests: +16 (MCP-JSON-RPC 6, MCP-HTTP-E2E 1, Profile 2, bestehende erweitert). Build 0 Fehler,
360 Tests gruen, --smoke-ui alle 5 Views gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-18 11:00:28 +02:00
co-authored by Claude Opus 4.8
parent 5d732277b2
commit b7b2a141e3
16 changed files with 1369 additions and 111 deletions
+89
View File
@@ -0,0 +1,89 @@
using System.Text.Json;
using PolyTrader.Modules.Supervisor.Agent;
using PolyTrader.Modules.Supervisor.Mcp;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>
/// Sicherheitsnetz für MCP-Light (S-4): JSON-RPC-Handling (initialize, tools/list, tools/call,
/// Notifications, Fehlerfälle) über die read-only Tool-Registry — pur, ohne HTTP.
/// </summary>
public class McpJsonRpcTests
{
private static SupervisorToolRegistry Registry()
{
var reg = new SupervisorToolRegistry();
reg.Register(new SupervisorTool("echo", "Echo-Tool",
"""{"type":"object","properties":{"text":{"type":"string"}}}""",
args => "ECHO:" + (SupervisorToolRegistry.GetString(args, "text") ?? "")));
return reg;
}
[Fact]
public void Initialize_returns_protocol_and_serverinfo()
{
string? resp = McpJsonRpc.Handle(
"""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}""",
Registry());
Assert.NotNull(resp);
using var doc = JsonDocument.Parse(resp!);
var result = doc.RootElement.GetProperty("result");
Assert.Equal(McpJsonRpc.ProtocolVersion, result.GetProperty("protocolVersion").GetString());
Assert.Equal(McpJsonRpc.ServerName, result.GetProperty("serverInfo").GetProperty("name").GetString());
Assert.True(result.GetProperty("capabilities").TryGetProperty("tools", out _));
}
[Fact]
public void ToolsList_exposes_registry_tools_with_schema()
{
string? resp = McpJsonRpc.Handle("""{"jsonrpc":"2.0","id":2,"method":"tools/list"}""", Registry());
using var doc = JsonDocument.Parse(resp!);
var tools = doc.RootElement.GetProperty("result").GetProperty("tools");
Assert.Equal(1, tools.GetArrayLength());
Assert.Equal("echo", tools[0].GetProperty("name").GetString());
Assert.Equal("object", tools[0].GetProperty("inputSchema").GetProperty("type").GetString());
}
[Fact]
public void ToolsCall_executes_and_wraps_result_as_text_content()
{
string? resp = McpJsonRpc.Handle(
"""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hallo"}}}""",
Registry());
using var doc = JsonDocument.Parse(resp!);
var result = doc.RootElement.GetProperty("result");
Assert.Equal("ECHO:hallo", result.GetProperty("content")[0].GetProperty("text").GetString());
Assert.False(result.GetProperty("isError").GetBoolean());
}
[Fact]
public void ToolsCall_unknown_tool_sets_isError()
{
string? resp = McpJsonRpc.Handle(
"""{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"nix","arguments":{}}}""",
Registry());
using var doc = JsonDocument.Parse(resp!);
Assert.True(doc.RootElement.GetProperty("result").GetProperty("isError").GetBoolean());
}
[Fact]
public void Notification_returns_null_and_unknown_method_returns_error()
{
Assert.Null(McpJsonRpc.Handle("""{"jsonrpc":"2.0","method":"notifications/initialized"}""", Registry()));
string? resp = McpJsonRpc.Handle("""{"jsonrpc":"2.0","id":5,"method":"gibtsnicht"}""", Registry());
using var doc = JsonDocument.Parse(resp!);
Assert.Equal(-32601, doc.RootElement.GetProperty("error").GetProperty("code").GetInt32());
}
[Fact]
public void Parse_error_returns_minus32700()
{
string? resp = McpJsonRpc.Handle("{kaputt", Registry());
using var doc = JsonDocument.Parse(resp!);
Assert.Equal(-32700, doc.RootElement.GetProperty("error").GetProperty("code").GetInt32());
}
}
}
@@ -0,0 +1,69 @@
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using PolyTrader.Modules.Supervisor.Agent;
using PolyTrader.Modules.Supervisor.Mcp;
using PolyTraderSharp.Services;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>End-to-End-Test des MCP-Light-HTTP-Hosts (echter HttpListener auf 127.0.0.1).</summary>
public class McpLightServerTests
{
[Fact]
public async Task Server_answers_initialize_and_tools_call_over_http()
{
int port = 52000 + new Random().Next(1000, 9000);
Environment.SetEnvironmentVariable("POLYTRADER_MCP_PORT", port.ToString());
try
{
var reg = new SupervisorToolRegistry();
reg.Register(new SupervisorTool("echo", "Echo",
"""{"type":"object","properties":{"text":{"type":"string"}}}""",
args => "ECHO:" + (SupervisorToolRegistry.GetString(args, "text") ?? "")));
var server = new McpLightServer(reg, new TerminalLogger());
await server.StartAsync(CancellationToken.None);
await Task.Delay(300); // Listener-Start abwarten
using var http = new HttpClient();
string url = $"http://127.0.0.1:{port}/mcp/";
// initialize
var initResp = await http.PostAsync(url, new StringContent(
"""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}""", Encoding.UTF8, "application/json"));
Assert.True(initResp.IsSuccessStatusCode);
using (var doc = JsonDocument.Parse(await initResp.Content.ReadAsStringAsync()))
Assert.Equal(McpJsonRpc.ServerName,
doc.RootElement.GetProperty("result").GetProperty("serverInfo").GetProperty("name").GetString());
// tools/call
var callResp = await http.PostAsync(url, new StringContent(
"""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"text":"mcp"}}}""",
Encoding.UTF8, "application/json"));
using (var doc = JsonDocument.Parse(await callResp.Content.ReadAsStringAsync()))
Assert.Equal("ECHO:mcp",
doc.RootElement.GetProperty("result").GetProperty("content")[0].GetProperty("text").GetString());
// Notification -> 202
var notifyResp = await http.PostAsync(url, new StringContent(
"""{"jsonrpc":"2.0","method":"notifications/initialized"}""", Encoding.UTF8, "application/json"));
Assert.Equal(202, (int)notifyResp.StatusCode);
// GET -> 405 (kein SSE-Stream)
var getResp = await http.GetAsync(url);
Assert.Equal(405, (int)getResp.StatusCode);
await server.StopAsync(CancellationToken.None);
}
finally
{
Environment.SetEnvironmentVariable("POLYTRADER_MCP_PORT", null);
}
}
}
}
@@ -51,6 +51,7 @@ namespace PolyTrader.Tests
{
private readonly Queue<ChatResponse> _script;
public List<IReadOnlyList<ChatMessage>> Requests { get; } = new();
public List<IReadOnlyList<SupervisorTool>> OfferedTools { get; } = new();
public ScriptedChatClient(params ChatResponse[] script) => _script = new Queue<ChatResponse>(script);
@@ -58,6 +59,7 @@ namespace PolyTrader.Tests
IReadOnlyList<SupervisorTool> tools, CancellationToken ct)
{
Requests.Add(new List<ChatMessage>(messages));
OfferedTools.Add(new List<SupervisorTool>(tools));
return Task.FromResult(_script.Count > 0 ? _script.Dequeue() : new ChatResponse { Content = "leer" });
}
}
@@ -154,5 +156,32 @@ namespace PolyTrader.Tests
Assert.Contains("PolyTrader", ctx);
Assert.Contains("read-only", ctx);
}
// ----- Profile (S-3) -----
[Fact]
public async Task Technik_profile_filters_tools_and_extends_prompt()
{
var reg = RegistryWithEcho();
reg.Register(new SupervisorTool("read_logs", "Logs", """{"type":"object","properties":{}}""", _ => "logs"));
var chat = new ScriptedChatClient(new ChatResponse { Content = "ok" });
var agent = new SupervisorAgent(chat, reg);
await agent.AskAsync("check", profile: SupervisorProfiles.Technik);
// Prompt trägt den Profil-Fokus; dem Modell wurde NUR das Technik-Subset angeboten
// (Registry hat echo/boom/read_logs → im Technik-Filter ist davon nur read_logs).
Assert.Contains("FOKUS TECHNIK-SUPERVISOR", chat.Requests[0][0].Content);
Assert.Single(chat.OfferedTools[0]);
Assert.Equal("read_logs", chat.OfferedTools[0][0].Name);
}
[Fact]
public void Profiles_lookup_is_case_insensitive_with_fallback()
{
Assert.Equal("Technik", SupervisorProfiles.ByName("technik").Name);
Assert.Equal("Allgemein", SupervisorProfiles.ByName("gibtsnicht").Name);
Assert.Equal(4, SupervisorProfiles.All.Count);
}
}
}