diff --git a/WelsonJS.Augmented/WelsonJS.Launcher/ApiEndpoints/JsonRpc2.cs b/WelsonJS.Augmented/WelsonJS.Launcher/ApiEndpoints/JsonRpc2.cs new file mode 100644 index 0000000..5964ee5 --- /dev/null +++ b/WelsonJS.Augmented/WelsonJS.Launcher/ApiEndpoints/JsonRpc2.cs @@ -0,0 +1,96 @@ +using log4net; +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace WelsonJS.Launcher.ApiEndpoints +{ + public class JsonRpc2 : IApiEndpoint + { + private readonly ResourceServer Server; + private readonly HttpClient _httpClient; + private readonly ILog _logger; + private const string Prefix = "jsonrpc2"; + + public JsonRpc2(ResourceServer server, HttpClient httpClient, ILog logger) + { + Server = server; + + _httpClient = httpClient; + _logger = logger; + } + + public bool CanHandle(HttpListenerContext context, string path) + { + if (context == null) + return false; + + if (!string.Equals(context.Request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase)) + return false; + + if (path.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase) == false) + return false; + + string contentType = context.Request.ContentType?.ToLowerInvariant() ?? string.Empty; + if (!(contentType.StartsWith("application/json") + || contentType.StartsWith("application/json-rpc") + || contentType.StartsWith("application/jsonrpc") + || contentType.StartsWith("text/json"))) + return false; + + if (!context.Request.HasEntityBody) + return false; + + return true; + } + + public async Task HandleAsync(HttpListenerContext context, string path) + { + string body; + try + { + using (var input = context.Request.InputStream) + using (var reader = new System.IO.StreamReader(input, System.Text.Encoding.UTF8)) + { + body = await reader.ReadToEndAsync(); + } + + _logger.Debug($"[JsonRpc2] Request body received ({body.Length} bytes)"); + } + catch (Exception ex) + { + _logger.Error("[JsonRpc2] Failed to read request body", ex); + + context.Response.StatusCode = (int)HttpStatusCode.BadRequest; + context.Response.Close(); + return; + } + + var dispatcher = new JsonRpc2Dispatcher(_logger); + + using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(300))) + { + await dispatcher.HandleAsync( + body, + async (method, ser, ct) => + { + switch (method) + { + case "tools/list": + await Server.ServeResource(context, ResourceServer.GetResource("McpToolsList.json"), "application/json"); + break; + + case "tools/call": + // TODO: implement tool calling + break; + } + + return string.Empty; + }, + cts.Token); + } + } + } +} diff --git a/WelsonJS.Augmented/WelsonJS.Launcher/JsonRpc2Dispatcher.cs b/WelsonJS.Augmented/WelsonJS.Launcher/JsonRpc2Dispatcher.cs new file mode 100644 index 0000000..cf29716 --- /dev/null +++ b/WelsonJS.Augmented/WelsonJS.Launcher/JsonRpc2Dispatcher.cs @@ -0,0 +1,80 @@ +using log4net; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace WelsonJS.Launcher +{ + public sealed class JsonRpc2Request + { + public string Version; + public string Method; + } + + public sealed class JsonRpc2Exception : Exception + { + public JsonRpc2Exception(string message) : base(message) { } + public JsonRpc2Exception(string message, Exception inner) : base(message, inner) { } + } + + public sealed class JsonRpc2Dispatcher + { + private readonly ILog _logger; + + public JsonRpc2Dispatcher(ILog logger) + { + _logger = logger; + } + + public async Task HandleAsync( + string requestBody, + Func> dispatchMethodAsync, + CancellationToken ct) + { + if (string.IsNullOrEmpty(requestBody)) + throw new JsonRpc2Exception("Empty request body"); + + if (dispatchMethodAsync == null) + throw new ArgumentNullException("dispatchMethodAsync"); + + using (var ser = new JsSerializer()) + { + int id = ser.Load(requestBody); + + try + { + string version = ser.ExtractFrom(id, "jsonrpc"); + if (!string.Equals(version, "2.0", StringComparison.Ordinal)) + throw new JsonRpc2Exception("Unsupported jsonrpc version: " + version); + + string method = ser.ExtractFrom(id, "method"); + if (string.IsNullOrEmpty(method)) + throw new JsonRpc2Exception("Missing method"); + + var req = new JsonRpc2Request + { + Version = version, + Method = method + }; + + return await dispatchMethodAsync(req.Method, ser, ct); + } + catch (JsonRpc2Exception) + { + throw; + } + catch (Exception ex) + { + if (_logger != null) + _logger.Error("[JsonRpc2] Parse error", ex); + + throw new JsonRpc2Exception("Parse error", ex); + } + finally + { + ser.Unload(id); + } + } + } + } +} \ No newline at end of file diff --git a/WelsonJS.Augmented/WelsonJS.Launcher/McpToolsList.json b/WelsonJS.Augmented/WelsonJS.Launcher/McpToolsList.json new file mode 100644 index 0000000..26ca8bc --- /dev/null +++ b/WelsonJS.Augmented/WelsonJS.Launcher/McpToolsList.json @@ -0,0 +1,28 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "tools": [ + { + "name": "run_jsrt_script", + "title": "Run JSRT Script", + "description": "Execute a JavaScript script on WelsonJS and WSH JSRT (JScript).\n\nOnly ES3-compatible syntax is allowed. ES5 or newer language syntax is not supported.\nA very limited set of ES5 runtime APIs may be available through host-provided compatibility shims, on a best-effort basis and without guarantees.\nDo not rely on strict mode, modern language features, or full polyfill behavior.\nAssume a minimal ES3 execution environment.", + "inputSchema": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "JavaScript source code to execute. The script must be written using ES3-compatible syntax and must be suitable for execution in WSH JSRT / WelsonJS." + }, + "timeoutMs": { + "type": "integer", + "description": "Maximum execution time in milliseconds. If the timeout is exceeded, the script execution may be forcibly terminated by the host.", + "minimum": 0 + } + }, + "required": [ "script" ] + } + } + ] + } +} \ No newline at end of file diff --git a/WelsonJS.Augmented/WelsonJS.Launcher/Program.cs b/WelsonJS.Augmented/WelsonJS.Launcher/Program.cs index d4f8c57..67f5ae4 100644 --- a/WelsonJS.Augmented/WelsonJS.Launcher/Program.cs +++ b/WelsonJS.Augmented/WelsonJS.Launcher/Program.cs @@ -11,9 +11,14 @@ using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; +using System.Net.Http; using System.Reflection; +using System.Runtime.Remoting.Contexts; +using System.Text; using System.Threading; +using System.Threading.Tasks; using System.Windows.Forms; +using static System.Windows.Forms.VisualStyles.VisualStyleElement.Tab; namespace WelsonJS.Launcher { @@ -49,7 +54,8 @@ namespace WelsonJS.Launcher string targetFilePath = GetTargetFilePath(args); if (!string.IsNullOrEmpty(targetFilePath)) { - try { + try + { HandleTargetFilePath(targetFilePath); } catch (Exception e) @@ -59,6 +65,13 @@ namespace WelsonJS.Launcher return; } + // if use stdio JSON-RPC 2.0 mode + if (HasArg(args, "--stdio-jsonrpc2")) + { + RunJsonRpc2StdioServer(); + return; + } + // create the mutex _mutex = new Mutex(true, "WelsonJS.Launcher", out bool createdNew); if (!createdNew) @@ -81,6 +94,54 @@ namespace WelsonJS.Launcher _mutex.Dispose(); } + private static bool HasArg(string[] args, string key) + { + if (args == null) + return false; + + for (int i = 0; i < args.Length; i++) + if (string.Equals(args[i], key, StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + + private static void RunJsonRpc2StdioServer() + { + var server = new StdioServer(async (payload, ct) => + { + var dispatcher = new JsonRpc2Dispatcher(_logger); + var body = Encoding.UTF8.GetString(payload); + + using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(300))) + { + string result = await dispatcher.HandleAsync( + body, + async (method, ser, _ct) => + { + switch (method) + { + case "tools/list": + return Encoding.UTF8.GetString(ResourceServer.GetResource("McpToolList.json")); + + case "tools/call": + // TODO: implement tool call handling + return string.Empty; + + default: + return string.Empty; + } + }, + cts.Token); + + // Fix: Convert string result to byte[] before returning + return Encoding.UTF8.GetBytes(result); + } + }); + + server.Run(); + } + private static void InitializeAssemblyLoader() { byte[] gzBytes = Properties.Resources.Phantomizer; @@ -182,7 +243,8 @@ namespace WelsonJS.Launcher private static string GetTargetFilePath(string[] args) { - if (args == null || args.Length == 0) return null; + if (args == null || args.Length == 0) + return null; for (int i = 0; i < args.Length; i++) { @@ -498,7 +560,7 @@ namespace WelsonJS.Launcher public static void InitializeResourceServer() { - lock(typeof(Program)) + lock (typeof(Program)) { if (_resourceServer == null) { diff --git a/WelsonJS.Augmented/WelsonJS.Launcher/ResourceServer.cs b/WelsonJS.Augmented/WelsonJS.Launcher/ResourceServer.cs index 2a83aef..b6c780a 100644 --- a/WelsonJS.Augmented/WelsonJS.Launcher/ResourceServer.cs +++ b/WelsonJS.Augmented/WelsonJS.Launcher/ResourceServer.cs @@ -4,8 +4,6 @@ // https://github.com/gnh1201/welsonjs // using log4net; -using log4net.Core; -using Microsoft.Isam.Esent.Interop; using System; using System.Collections.Generic; using System.IO; @@ -75,6 +73,7 @@ namespace WelsonJS.Launcher _apis.Add(new ApiEndpoints.TwoFactorAuth(this, _httpClient, _logger)); _apis.Add(new ApiEndpoints.Whois(this, _httpClient, _logger)); _apis.Add(new ApiEndpoints.ImageColorPicker(this, _httpClient, _logger)); + _apis.Add(new ApiEndpoints.JsonRpc2(this, _httpClient, _logger)); // Register the prefix _listener.Prefixes.Add(prefix); @@ -164,8 +163,8 @@ namespace WelsonJS.Launcher // Serve from the blob server if (await ServeBlob(context, path)) return; - // Fallback to serve from a resource name - await ServeResource(context, GetResource(_resourceName), "text/html"); + // Fallback to 404 (Not Found) + await ServeResource(context); } private async Task ServeBlob(HttpListenerContext context, string path, string prefix = null) @@ -398,7 +397,7 @@ namespace WelsonJS.Launcher await ServeResource(context, Encoding.UTF8.GetBytes(data), mimeType, statusCode); } - private byte[] GetResource(string resourceName) + public static byte[] GetResource(string resourceName) { // Try to fetch embedded resource. byte[] data = GetEmbeddedResource(typeof(Program).Namespace + "." + resourceName); @@ -408,7 +407,7 @@ namespace WelsonJS.Launcher return GetResourceFromManager(resourceName); } - private byte[] GetEmbeddedResource(string fullResourceName) + private static byte[] GetEmbeddedResource(string fullResourceName) { Assembly assembly = Assembly.GetExecutingAssembly(); using (Stream stream = assembly.GetManifestResourceStream(fullResourceName)) @@ -425,7 +424,7 @@ namespace WelsonJS.Launcher return null; } - private byte[] GetResourceFromManager(string resourceName) + private static byte[] GetResourceFromManager(string resourceName) { object resourceObject = Properties.Resources.ResourceManager.GetObject(resourceName); switch (resourceObject) @@ -439,7 +438,7 @@ namespace WelsonJS.Launcher } } - private byte[] ConvertIconToBytes(System.Drawing.Icon icon) + private static byte[] ConvertIconToBytes(System.Drawing.Icon icon) { using (MemoryStream memoryStream = new MemoryStream()) { diff --git a/WelsonJS.Augmented/WelsonJS.Launcher/StdioServer.cs b/WelsonJS.Augmented/WelsonJS.Launcher/StdioServer.cs new file mode 100644 index 0000000..ac7524c --- /dev/null +++ b/WelsonJS.Augmented/WelsonJS.Launcher/StdioServer.cs @@ -0,0 +1,209 @@ +// 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 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 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 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 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 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]; + } + } +} diff --git a/WelsonJS.Augmented/WelsonJS.Launcher/WelsonJS.Launcher.csproj b/WelsonJS.Augmented/WelsonJS.Launcher/WelsonJS.Launcher.csproj index 17227f7..24970c3 100644 --- a/WelsonJS.Augmented/WelsonJS.Launcher/WelsonJS.Launcher.csproj +++ b/WelsonJS.Augmented/WelsonJS.Launcher/WelsonJS.Launcher.csproj @@ -91,9 +91,11 @@ + + @@ -131,6 +133,7 @@ GlobalSettingsForm.cs + EnvForm.cs @@ -153,6 +156,7 @@ PreserveNewest + SettingsSingleFileGenerator