Use HTTP forwarder for stdio-jsonrpc2

Replace the old StdioServer-based stdio JSON-RPC flow with an in-process HTTP forwarder. Program.cs now reads raw stdin, posts payloads to the configured ResourceServerPrefix + "jsonrpc2" using HttpClient (configurable timeout), writes HTTP response bytes back to stdout, and includes improved logging and SafePreviewUtf8 for safe payload previews. StdioServer.cs and its project entry were removed, and Resources.resx HttpClientTimeout was increased to 300 (Designer version updated). This centralizes stdio handling and HTTP forwarding with better error handling and diagnostics.
This commit is contained in:
Namhyeon Go 2026-02-06 15:10:04 +09:00
parent 33148ec826
commit 91e18bbb0e
5 changed files with 113 additions and 238 deletions

View File

@ -11,9 +11,12 @@ using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace WelsonJS.Launcher
@ -61,10 +64,11 @@ namespace WelsonJS.Launcher
return;
}
// if use stdio JSON-RPC 2.0 mode
// if use the stdio-jsonrpc2 forwarder
if (HasArg(args, "--stdio-jsonrpc2"))
{
RunJsonRpc2StdioServer();
_logger.Info("Starting in the stdio-jsonrpc2 forwarder...");
ProcessStdioJsonRpc2().GetAwaiter().GetResult();
return;
}
@ -102,40 +106,121 @@ namespace WelsonJS.Launcher
return false;
}
private static void RunJsonRpc2StdioServer()
private async static Task ProcessStdioJsonRpc2()
{
var server = new StdioServer(async (payload, ct) =>
string serverPrefix = GetAppConfig("ResourceServerPrefix");
string endpoint = $"{serverPrefix}jsonrpc2";
int timeout = int.TryParse(GetAppConfig("HttpClientTimeout"), out timeout) ? timeout : 300;
var http = new HttpClient
{
var dispatcher = new JsonRpc2Dispatcher(_logger);
var body = Encoding.UTF8.GetString(payload);
Timeout = TimeSpan.FromSeconds(timeout)
};
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(300)))
using (var stdin = Console.OpenStandardInput())
using (var stdout = Console.OpenStandardOutput())
{
var buffer = new byte[8192];
while (true)
{
string result = await dispatcher.HandleAsync(
body,
async (method, ser, _ct) =>
int read;
try
{
read = await stdin.ReadAsync(buffer, 0, buffer.Length);
}
catch (Exception ex)
{
_logger.Error("[stdio] stdin read failed", ex);
break;
}
if (read <= 0)
{
_logger.Info("[stdio] EOF received, exiting loop");
break;
}
byte[] payload = new byte[read];
Buffer.BlockCopy(buffer, 0, payload, 0, read);
_logger.Debug($"[stdio] recv {payload.Length} bytes");
_logger.Debug($"[stdio] payload preview: {SafePreviewUtf8(payload, 512)}");
try
{
using (var content = new ByteArrayContent(payload))
{
switch (method)
// Content-Type: application/json
content.Headers.ContentType =
new MediaTypeHeaderValue("application/json")
{
CharSet = "utf-8"
};
_logger.Debug($"[http] POST {endpoint}");
using (var response = await http.PostAsync(endpoint, content))
{
case "tools/list":
return Encoding.UTF8.GetString(ResourceServer.GetResource("McpToolList.json"));
_logger.Info(
$"[http] status={(int)response.StatusCode} {response.ReasonPhrase}");
case "tools/call":
// TODO: implement tool call handling
return string.Empty;
foreach (var h in response.Headers)
_logger.Debug($"[http] H {h.Key}: {string.Join(", ", h.Value)}");
default:
return string.Empty;
foreach (var h in response.Content.Headers)
_logger.Debug($"[http] HC {h.Key}: {string.Join(", ", h.Value)}");
byte[] responseBytes =
await response.Content.ReadAsByteArrayAsync();
_logger.Debug($"[http] body {responseBytes.Length} bytes");
_logger.Debug(
$"[http] body preview: {SafePreviewUtf8(responseBytes, 2048)}");
await stdout.WriteAsync(responseBytes, 0, responseBytes.Length);
await stdout.FlushAsync();
}
},
cts.Token);
// Fix: Convert string result to byte[] before returning
return Encoding.UTF8.GetBytes(result);
}
}
catch (TaskCanceledException ex)
{
_logger.Error("[http] request timed out or canceled", ex);
}
catch (HttpRequestException ex)
{
_logger.Error("[http] request failed", ex);
}
catch (Exception ex)
{
_logger.Error("[http] unexpected error", ex);
}
}
});
}
}
server.Run();
private static string SafePreviewUtf8(byte[] bytes, int maxBytes)
{
if (bytes == null || bytes.Length == 0)
return "(empty)";
try
{
int len = Math.Min(bytes.Length, maxBytes);
string s = Encoding.UTF8.GetString(bytes, 0, len);
s = s.Replace("\r", "\\r").Replace("\n", "\\n");
if (bytes.Length > maxBytes)
s += $"...(truncated, total {bytes.Length} bytes)";
return s;
}
catch
{
return "(binary / non-utf8 response)";
}
}
private static void InitializeAssemblyLoader()

View File

@ -19,7 +19,7 @@ namespace WelsonJS.Launcher.Properties {
// 클래스에서 자동으로 생성되었습니다.
// 멤버를 추가하거나 제거하려면 .ResX 파일을 편집한 다음 /str 옵션을 사용하여 ResGen을
// 다시 실행하거나 VS 프로젝트를 다시 빌드하십시오.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
@ -197,7 +197,7 @@ namespace WelsonJS.Launcher.Properties {
}
/// <summary>
/// 90과(와) 유사한 지역화된 문자열을 찾습니다.
/// 300과(와) 유사한 지역화된 문자열을 찾습니다.
/// </summary>
internal static string HttpClientTimeout {
get {

View File

@ -188,7 +188,7 @@
<value>https://catswords.blob.core.windows.net/welsonjs/blob.config.xml</value>
</data>
<data name="HttpClientTimeout" xml:space="preserve">
<value>90</value>
<value>300</value>
</data>
<data name="IpQueryApiKey" xml:space="preserve">
<value />

View File

@ -1,209 +0,0 @@
// StdioServer.cs
// Minimal stdio server (Content-Length framing) with delegate-based extension.
// - Handles ONLY stdio framing (read/write message boundaries)
// - No JSON parsing/formatting
// - Sequential processing
// - Cancellation via Ctrl+C, and EOF handling
//
// Delegate contract:
// - input: raw UTF-8 payload bytes (exactly Content-Length)
// - output: raw UTF-8 payload bytes to write (or null/empty to write nothing)
//
// Typical use: plug JSON-RPC/MCP dispatcher outside of this class.
using System;
using System.Globalization;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace WelsonJS.Launcher
{
public sealed class StdioServer
{
public delegate Task<byte[]> Handler(byte[] payload, CancellationToken ct);
private readonly Stream _inStream;
private readonly Stream _outStream;
private readonly Handler _handler;
public StdioServer(Handler handler)
{
if (handler == null)
throw new ArgumentNullException("handler");
_handler = handler;
_inStream = Console.OpenStandardInput();
_outStream = Console.OpenStandardOutput();
}
public void Run()
{
using (var cts = new CancellationTokenSource())
{
Console.CancelKeyPress += (s, e) =>
{
e.Cancel = true;
cts.Cancel();
};
RunAsync(cts.Token).GetAwaiter().GetResult();
}
}
public async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
byte[] payload;
// 1) read one framed message (blocks here waiting for stdin)
try
{
payload = await ReadOneAsync(ct).ConfigureAwait(false);
if (payload == null) return; // EOF => exit
}
catch (OperationCanceledException)
{
return;
}
catch
{
// framing broken or stream error => stop (or continue if you want resync)
return;
}
// 2) handle + write response (never kill the loop on handler failure)
try
{
var resp = await _handler(payload, ct).ConfigureAwait(false);
if (resp == null) resp = new byte[0];
await WriteOneAsync(resp, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
catch
{
// keep listening even if handler fails
// optionally write empty response so client doesn't hang waiting
try { await WriteOneAsync(new byte[0], ct).ConfigureAwait(false); } catch { }
continue;
}
}
}
private async Task<byte[]> ReadOneAsync(CancellationToken ct)
{
// Read headers until \r\n\r\n (blocks on stdin)
string headers = await ReadHeadersAsync(ct).ConfigureAwait(false);
if (headers == null) return null; // EOF
int contentLength = ParseContentLength(headers);
if (contentLength < 0) throw new InvalidDataException("Missing Content-Length");
return await ReadExactAsync(_inStream, contentLength, ct).ConfigureAwait(false);
}
private async Task WriteOneAsync(byte[] payload, CancellationToken ct)
{
if (payload == null) payload = new byte[0];
string header = "Content-Length: " + payload.Length.ToString(CultureInfo.InvariantCulture) + "\r\n\r\n";
byte[] headerBytes = Encoding.ASCII.GetBytes(header);
await _outStream.WriteAsync(headerBytes, 0, headerBytes.Length, ct).ConfigureAwait(false);
if (payload.Length > 0)
await _outStream.WriteAsync(payload, 0, payload.Length, ct).ConfigureAwait(false);
await _outStream.FlushAsync(ct).ConfigureAwait(false);
}
private async Task<string> ReadHeadersAsync(CancellationToken ct)
{
// read byte-by-byte until CRLFCRLF
var buf = new byte[4096];
int len = 0;
while (true)
{
ct.ThrowIfCancellationRequested();
int b = await ReadByteAsync(_inStream, ct).ConfigureAwait(false);
if (b < 0)
{
if (len == 0) return null; // clean EOF
throw new EndOfStreamException("EOF while reading headers");
}
if (len == buf.Length)
{
// grow
var nb = new byte[buf.Length * 2];
Buffer.BlockCopy(buf, 0, nb, 0, buf.Length);
buf = nb;
}
buf[len++] = (byte)b;
if (len >= 4 &&
buf[len - 4] == 13 &&
buf[len - 3] == 10 &&
buf[len - 2] == 13 &&
buf[len - 1] == 10)
{
return Encoding.ASCII.GetString(buf, 0, len);
}
}
}
private static int ParseContentLength(string headers)
{
// minimal parser: Content-Length: N
var lines = headers.Split(new[] { "\r\n" }, StringSplitOptions.None);
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i];
int colon = line.IndexOf(':');
if (colon <= 0) continue;
var name = line.Substring(0, colon).Trim();
if (!name.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) continue;
var val = line.Substring(colon + 1).Trim();
int n;
if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out n))
return n;
}
return -1;
}
private static async Task<byte[]> ReadExactAsync(Stream s, int nBytes, CancellationToken ct)
{
if (nBytes == 0) return new byte[0];
var buf = new byte[nBytes];
int read = 0;
while (read < nBytes)
{
ct.ThrowIfCancellationRequested();
int n = await s.ReadAsync(buf, read, nBytes - read, ct).ConfigureAwait(false);
if (n <= 0) throw new EndOfStreamException("EOF while reading payload");
read += n;
}
return buf;
}
private static async Task<int> ReadByteAsync(Stream s, CancellationToken ct)
{
var b = new byte[1];
int n = await s.ReadAsync(b, 0, 1, ct).ConfigureAwait(false);
if (n <= 0) return -1;
return b[0];
}
}
}

View File

@ -133,7 +133,6 @@
<DependentUpon>GlobalSettingsForm.cs</DependentUpon>
</Compile>
<Compile Include="ResourceServer.cs" />
<Compile Include="StdioServer.cs" />
<Compile Include="WebSocketManager.cs" />
<EmbeddedResource Include="EnvForm.resx">
<DependentUpon>EnvForm.cs</DependentUpon>