From 62c681f4796546ebf13e6a230bcfe6e38e02407e Mon Sep 17 00:00:00 2001 From: a-panizza_globant Date: Thu, 16 Oct 2025 14:21:00 -0300 Subject: [PATCH 1/4] - Join MCP Server and Web server in the same application. - Add conditional command line parameter to enable4/disable Mcp use --- .../GxNetCoreStartup/GxNetCoreStartup.csproj | 21 ++++- .../dotnetcore/GxNetCoreStartup/RunUtils.cs | 94 ++++++++----------- .../dotnetcore/GxNetCoreStartup/Startup.cs | 86 +++++++++++------ 3 files changed, 109 insertions(+), 92 deletions(-) diff --git a/dotnet/src/dotnetcore/GxNetCoreStartup/GxNetCoreStartup.csproj b/dotnet/src/dotnetcore/GxNetCoreStartup/GxNetCoreStartup.csproj index 72b8aa3c7..d9c3fbf25 100644 --- a/dotnet/src/dotnetcore/GxNetCoreStartup/GxNetCoreStartup.csproj +++ b/dotnet/src/dotnetcore/GxNetCoreStartup/GxNetCoreStartup.csproj @@ -8,6 +8,15 @@ false $(TargetsForTfmSpecificContentInPackage);CustomContentTarget + + $(NoWarn);NU5104 + + + NU1900;NU1901;NU1902;NU1903;NU1904;NU5104 + + + NU1900;NU1901;NU1902;NU1903;NU1904;NU5104 + @@ -27,12 +36,14 @@ - + + + + + + + - - - - diff --git a/dotnet/src/dotnetcore/GxNetCoreStartup/RunUtils.cs b/dotnet/src/dotnetcore/GxNetCoreStartup/RunUtils.cs index 4907d0209..08a1ed1f7 100644 --- a/dotnet/src/dotnetcore/GxNetCoreStartup/RunUtils.cs +++ b/dotnet/src/dotnetcore/GxNetCoreStartup/RunUtils.cs @@ -1,72 +1,54 @@ using System; +using System.Collections.Generic; using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; using System.Text; namespace GeneXus.Application { - public static class GxRunner + + internal static class FileTools { - public static void RunAsync( - string commandLine, - string workingDir, - string virtualPath, - string schema, - Action onExit = null) + static readonly IGXLogger log = GXLoggerFactory.GetLogger(typeof(FileTools).FullName); + public static List MCPFileTools(string baseDirectory) { - var stdout = new StringBuilder(); - var stderr = new StringBuilder(); - - using var proc = new Process + // List of Assemblies with MCP tools + List mcpAssemblies = new(); + string currentBin = Path.Combine(baseDirectory, "bin"); + if (!Directory.Exists(currentBin)) { - StartInfo = new ProcessStartInfo + currentBin = baseDirectory; + if (!Directory.Exists(currentBin)) { - FileName = commandLine, - WorkingDirectory = workingDir, - UseShellExecute = false, // required for redirection - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = false, // flip to true only if you need to write to stdin - StandardOutputEncoding = Encoding.UTF8, - StandardErrorEncoding = Encoding.UTF8 - }, - EnableRaisingEvents = true - }; - - proc.StartInfo.ArgumentList.Add(virtualPath); - proc.StartInfo.ArgumentList.Add(schema); - - proc.OutputDataReceived += (_, e) => - { - if (e.Data is null) return; - stdout.AppendLine(e.Data); - Console.WriteLine(e.Data); // forward to parent console (stdout) - }; - - proc.ErrorDataReceived += (_, e) => - { - if (e.Data is null) return; - stderr.AppendLine(e.Data); - Console.Error.WriteLine(e.Data); // forward to parent console (stderr) - }; - - proc.Exited += (sender, e) => + currentBin = ""; + } + } + if (!String.IsNullOrEmpty(currentBin)) { - var p = (Process)sender!; - int exitCode = p.ExitCode; - p.Dispose(); - - Console.WriteLine($"[{DateTime.Now:T}] Process exited with code {exitCode}"); - - // Optional: call user-provided callback - onExit?.Invoke(exitCode); - }; - - if (!proc.Start()) - throw new InvalidOperationException("Failed to start process"); - Console.WriteLine($"[{DateTime.Now:T}] MCP Server Started."); + GXLogging.Info(log, $"[{DateTime.Now:T}] Registering MCP tools."); + List L = Directory.GetFiles(currentBin, "*mcp_service.dll").ToList(); + foreach (string mcpFile in L) + { + var assembly = Assembly.LoadFrom(mcpFile); + foreach (var tool in assembly.GetTypes()) + { + // Each MCP Assembly is added to the list to return for registration + var attributes = tool.GetCustomAttributes().Where(att => att.ToString() == "ModelContextProtocol.Server.McpServerToolTypeAttribute"); + if (attributes != null && attributes.Any()) + { + GXLogging.Info(log, $"[{DateTime.Now:T}] Loading tool {mcpFile}."); + mcpAssemblies.Add(assembly); + } + } + } + } + return mcpAssemblies; } } + + } diff --git a/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs b/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs index 7e11e9e7b..d25f73b19 100644 --- a/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs +++ b/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs @@ -12,6 +12,9 @@ using GeneXus.Services.OpenTelemetry; using GeneXus.Utils; using GxClasses.Web.Middleware; + +using iText.Commons.Actions; + using Microsoft.AspNetCore; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Builder; @@ -36,6 +39,9 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; +using Microsoft.Identity.Client; + +using ModelContextProtocol.AspNetCore; using StackExchange.Redis; namespace GeneXus.Application @@ -59,6 +65,8 @@ public static void Main(string[] args) port = args[2]; if (args.Length > 3 && Uri.UriSchemeHttps.Equals(args[3], StringComparison.OrdinalIgnoreCase)) schema = Uri.UriSchemeHttps; + if (args.Length > 4) + Startup.IsMcp = args[4].Equals("mcp", StringComparison.OrdinalIgnoreCase); } else { @@ -66,7 +74,6 @@ public static void Main(string[] args) } - MCPTools.ServerStart(Startup.VirtualPath, Startup.LocalPath, schema); if (port == DEFAULT_PORT) { @@ -167,6 +174,7 @@ public class Startup const long DEFAULT_MAX_FILE_UPLOAD_SIZE_BYTES = 528000000; public static string VirtualPath = string.Empty; public static string LocalPath = Directory.GetCurrentDirectory(); + public static bool IsMcp = false; internal static string APP_SETTINGS = "appsettings.json"; const string UrlTemplateControllerWithParms = "controllerWithParms"; @@ -277,6 +285,47 @@ public void ConfigureServices(IServiceCollection services) options.SuppressXFrameOptionsHeader = true; }); } + if (Startup.IsMcp) + { + var mcp = services.AddMcpServer(options => + { + options.ServerInfo = new ModelContextProtocol.Protocol.Implementation + { + Name = "GxMcpServer", + Version = Assembly.GetExecutingAssembly() + .GetCustomAttribute()?.InformationalVersion ?? "1.0.0" + }; + }) + .WithHttpTransport(transportOptions => + { + // SSE endpoints (/sse, /message) require STATEFUL sessions to support server-to-client push + transportOptions.Stateless = false; + transportOptions.IdleTimeout = TimeSpan.FromSeconds(); + GXLogging.Debug(log, "MCP HTTP Transport configured: Stateless=false (SSE enabled), IdleTimeout=10min"); + }); + + try + { + var mcpAssemblies = FileTools.MCPFileTools(Startup.LocalPath).ToList(); + foreach (var assembly in mcpAssemblies) + { + try + { + mcp.WithToolsFromAssembly(assembly); + GXLogging.Debug(log, $"Successfully loaded MCP tools from assembly: {assembly.FullName}"); + } + catch (Exception assemblyEx) + { + GXLogging.Error(log, $"Failed to load MCP tools from assembly: {assembly.FullName}", assemblyEx); + } + } + } + catch (Exception ex) + { + GXLogging.Error(log, "Error discovering MCP tool assemblies", ex); + } + } + services.AddDirectoryBrowser(); if (GXUtil.CompressResponse()) { @@ -590,6 +639,11 @@ public void Configure(IApplicationBuilder app, Microsoft.AspNetCore.Hosting.IHos { Predicate = check => check.Tags.Contains("ready") }); + if (Startup.IsMcp) + { + // Register MCP endpoints at root, exposing /sse and /message + endpoints.MapMcp(); + } }); if (log.IsCriticalEnabled && env.IsDevelopment()) { @@ -867,36 +921,6 @@ public void Apply(ApplicationModel application) } } - static class MCPTools - { - public static void ServerStart(string virtualPath, string workingDir, string schema) - { - IGXLogger log = GXLoggerFactory.GetLogger(typeof(CustomBadRequestObjectResult).FullName); - bool isMpcServer = false; - try - { - List L = Directory.GetFiles(Path.Combine(workingDir,"bin"), "*mcp_service.dll").ToList(); - isMpcServer = L.Count > 0; - if (isMpcServer) - { - GXLogging.Info(log, "Start MCP Server"); - - GxRunner.RunAsync("GxMcpStartup.exe", Path.Combine(workingDir,"bin"), virtualPath, schema, onExit: exitCode => - { - if (exitCode == 0) - Console.WriteLine("Process completed successfully."); - else - Console.Error.WriteLine($"Process failed (exit code {exitCode})"); - }); - } - } - catch (Exception ex) - { - GXLogging.Error(log, "Error starting MCP Server", ex); - } - } - } - internal class HomeControllerConvention : IApplicationModelConvention { From 26d73434674aedde33a7a38f4b390e3b9ae221b6 Mon Sep 17 00:00:00 2001 From: Alejandro Panizza Carve Date: Thu, 16 Oct 2025 16:36:48 -0300 Subject: [PATCH 2/4] Set IdleTimeout to 30 seconds for transport options --- dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs b/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs index d25f73b19..bd7ea20e3 100644 --- a/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs +++ b/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs @@ -300,7 +300,7 @@ public void ConfigureServices(IServiceCollection services) { // SSE endpoints (/sse, /message) require STATEFUL sessions to support server-to-client push transportOptions.Stateless = false; - transportOptions.IdleTimeout = TimeSpan.FromSeconds(); + transportOptions.IdleTimeout = TimeSpan.FromSeconds(30); GXLogging.Debug(log, "MCP HTTP Transport configured: Stateless=false (SSE enabled), IdleTimeout=10min"); }); From 291ac3731870f63eea4cb70edb03430ccef128cd Mon Sep 17 00:00:00 2001 From: a-panizza_globant Date: Fri, 24 Oct 2025 09:20:33 -0300 Subject: [PATCH 3/4] - Extract Mcp code to a different source for incremental loading and code separation. --- .../dotnetcore/GxNetCoreStartup/StartMcp.cs | 67 +++++++++++ .../dotnetcore/GxNetCoreStartup/Startup.cs | 107 ++++++------------ 2 files changed, 101 insertions(+), 73 deletions(-) create mode 100644 dotnet/src/dotnetcore/GxNetCoreStartup/StartMcp.cs diff --git a/dotnet/src/dotnetcore/GxNetCoreStartup/StartMcp.cs b/dotnet/src/dotnetcore/GxNetCoreStartup/StartMcp.cs new file mode 100644 index 000000000..f466076e0 --- /dev/null +++ b/dotnet/src/dotnetcore/GxNetCoreStartup/StartMcp.cs @@ -0,0 +1,67 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Security.Policy; +using ModelContextProtocol.AspNetCore; +using GeneXus.Utils; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Builder; + +namespace GeneXus.Application +{ + public class StartupMcp + { + static readonly IGXLogger log = GXLoggerFactory.GetLogger(typeof(StartupMcp).FullName); + public static void AddService(IServiceCollection services) + { + Console.Out.WriteLine("Starting MCP Server..."); + var mcp = services.AddMcpServer(options => + { + options.ServerInfo = new ModelContextProtocol.Protocol.Implementation + { + Name = "GxMcpServer", + Version = Assembly.GetExecutingAssembly() + .GetCustomAttribute()?.InformationalVersion ?? "1.0.0" + }; + }) + .WithHttpTransport(transportOptions => + { + // SSE endpoints (/sse, /message) require STATEFUL sessions to support server-to-client push + transportOptions.Stateless = false; + transportOptions.IdleTimeout = TimeSpan.FromMinutes(5); + GXLogging.Debug(log, "MCP HTTP Transport configured: Stateless=false (SSE enabled), IdleTimeout=5min"); + }); + + try + { + var mcpAssemblies = FileTools.MCPFileTools(Startup.LocalPath).ToList(); + foreach (var assembly in mcpAssemblies) + { + try + { + mcp.WithToolsFromAssembly(assembly); + GXLogging.Debug(log, $"Successfully loaded MCP tools from assembly: {assembly.FullName}"); + } + catch (Exception assemblyEx) + { + GXLogging.Error(log, $"Failed to load MCP tools from assembly: {assembly.FullName}", assemblyEx); + } + } + } + catch (Exception ex) + { + GXLogging.Error(log, "Error discovering MCP tool assemblies", ex); + } + } + + public static void MapEndpoints(IEndpointRouteBuilder endpoints) + { + // Register MCP endpoints at root, exposing /sse and /message + endpoints.MapMcp(); + GXLogging.Debug(log, "MCP Routing configured."); + + } + } +} diff --git a/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs b/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs index bd7ea20e3..6508ff36a 100644 --- a/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs +++ b/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs @@ -41,7 +41,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Identity.Client; -using ModelContextProtocol.AspNetCore; using StackExchange.Redis; namespace GeneXus.Application @@ -74,7 +73,6 @@ public static void Main(string[] args) } - if (port == DEFAULT_PORT) { BuildWebHost(null).Run(); @@ -88,7 +86,7 @@ public static void Main(string[] args) { Console.Error.WriteLine("ERROR:"); Console.Error.WriteLine("Web Host terminated unexpectedly: {0}", e.Message); - } + } } public static IWebHost BuildWebHost(string[] args) => @@ -287,43 +285,7 @@ public void ConfigureServices(IServiceCollection services) } if (Startup.IsMcp) { - var mcp = services.AddMcpServer(options => - { - options.ServerInfo = new ModelContextProtocol.Protocol.Implementation - { - Name = "GxMcpServer", - Version = Assembly.GetExecutingAssembly() - .GetCustomAttribute()?.InformationalVersion ?? "1.0.0" - }; - }) - .WithHttpTransport(transportOptions => - { - // SSE endpoints (/sse, /message) require STATEFUL sessions to support server-to-client push - transportOptions.Stateless = false; - transportOptions.IdleTimeout = TimeSpan.FromSeconds(30); - GXLogging.Debug(log, "MCP HTTP Transport configured: Stateless=false (SSE enabled), IdleTimeout=10min"); - }); - - try - { - var mcpAssemblies = FileTools.MCPFileTools(Startup.LocalPath).ToList(); - foreach (var assembly in mcpAssemblies) - { - try - { - mcp.WithToolsFromAssembly(assembly); - GXLogging.Debug(log, $"Successfully loaded MCP tools from assembly: {assembly.FullName}"); - } - catch (Exception assemblyEx) - { - GXLogging.Error(log, $"Failed to load MCP tools from assembly: {assembly.FullName}", assemblyEx); - } - } - } - catch (Exception ex) - { - GXLogging.Error(log, "Error discovering MCP tool assemblies", ex); - } + StartupMcp.AddService(services); } services.AddDirectoryBrowser(); @@ -332,20 +294,20 @@ public void ConfigureServices(IServiceCollection services) services.AddResponseCompression(options => { options.MimeTypes = new[] - { - // Default - "text/plain", - "text/css", - "application/javascript", - "text/html", - "application/xml", - "text/xml", - "application/json", - "text/json", - // Custom - "application/json", - "application/pdf" - }; + { + // Default + "text/plain", + "text/css", + "application/javascript", + "text/html", + "application/xml", + "text/xml", + "application/json", + "text/json", + // Custom + "application/json", + "application/pdf" + }; options.EnableForHttps = true; }); } @@ -423,9 +385,9 @@ private void RegisterRestServices(IMvcBuilder mvcBuilder) try { string[] controllerAssemblyQualifiedName = new string(File.ReadLines(svcFile).First().SkipWhile(c => c != '"') - .Skip(1) - .TakeWhile(c => c != '"') - .ToArray()).Trim().Split(','); + .Skip(1) + .TakeWhile(c => c != '"') + .ToArray()).Trim().Split(','); string controllerAssemblyName = controllerAssemblyQualifiedName.Last(); if (!serviceAssemblies.Contains(controllerAssemblyName)) { @@ -486,17 +448,17 @@ private void DefineCorsPolicy(IServiceCollection services) services.AddCors(options => { options.AddPolicy(name: CORS_POLICY_NAME, - policy => - { - policy.WithOrigins(origins); - if (!corsAllowedOrigins.Contains(CORS_ANY_ORIGIN)) - { - policy.AllowCredentials(); - } - policy.AllowAnyHeader(); - policy.AllowAnyMethod(); - policy.SetPreflightMaxAge(TimeSpan.FromSeconds(CORS_MAX_AGE_SECONDS)); - }); + policy => + { + policy.WithOrigins(origins); + if (!corsAllowedOrigins.Contains(CORS_ANY_ORIGIN)) + { + policy.AllowCredentials(); + } + policy.AllowAnyHeader(); + policy.AllowAnyMethod(); + policy.SetPreflightMaxAge(TimeSpan.FromSeconds(CORS_MAX_AGE_SECONDS)); + }); }); } } @@ -550,7 +512,6 @@ private void ConfigureSessionService(IServiceCollection services, ISessionServic } else { - services.AddDistributedSqlServerCache(options => { GXLogging.Info(log, $"Using SQLServer for Distributed session, ConnectionString:{sessionService.ConnectionString}, SchemaName: {sessionService.Schema}, TableName: {sessionService.TableName}"); @@ -640,11 +601,11 @@ public void Configure(IApplicationBuilder app, Microsoft.AspNetCore.Hosting.IHos Predicate = check => check.Tags.Contains("ready") }); if (Startup.IsMcp) - { - // Register MCP endpoints at root, exposing /sse and /message - endpoints.MapMcp(); + { + StartupMcp.MapEndpoints(endpoints); } }); + if (log.IsCriticalEnabled && env.IsDevelopment()) { try @@ -696,7 +657,7 @@ public void Configure(IApplicationBuilder app, Microsoft.AspNetCore.Hosting.IHos }, ContentTypeProvider = provider }); - + app.UseExceptionHandler(new ExceptionHandlerOptions { ExceptionHandler = new CustomExceptionHandlerMiddleware().Invoke, From 00dce3ec849d5a3ab4e810ed54351ea16f100ce6 Mon Sep 17 00:00:00 2001 From: claudiamurialdo Date: Fri, 24 Oct 2025 09:34:53 -0300 Subject: [PATCH 4/4] Remove unused usings. --- .../dotnetcore/GxNetCoreStartup/Startup.cs | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs b/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs index 6508ff36a..f6510b89d 100644 --- a/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs +++ b/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs @@ -13,8 +13,6 @@ using GeneXus.Utils; using GxClasses.Web.Middleware; -using iText.Commons.Actions; - using Microsoft.AspNetCore; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Builder; @@ -39,7 +37,6 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; -using Microsoft.Identity.Client; using StackExchange.Redis; @@ -64,7 +61,7 @@ public static void Main(string[] args) port = args[2]; if (args.Length > 3 && Uri.UriSchemeHttps.Equals(args[3], StringComparison.OrdinalIgnoreCase)) schema = Uri.UriSchemeHttps; - if (args.Length > 4) + if (args.Length > 4) Startup.IsMcp = args[4].Equals("mcp", StringComparison.OrdinalIgnoreCase); } else @@ -86,7 +83,7 @@ public static void Main(string[] args) { Console.Error.WriteLine("ERROR:"); Console.Error.WriteLine("Web Host terminated unexpectedly: {0}", e.Message); - } + } } public static IWebHost BuildWebHost(string[] args) => @@ -596,13 +593,13 @@ public void Configure(IApplicationBuilder app, Microsoft.AspNetCore.Hosting.IHos Predicate = check => check.Tags.Contains("live") }); - endpoints.MapHealthChecks($"{baseVirtualPath }/_gx/health/ready", new HealthCheckOptions + endpoints.MapHealthChecks($"{baseVirtualPath}/_gx/health/ready", new HealthCheckOptions { Predicate = check => check.Tags.Contains("ready") }); if (Startup.IsMcp) - { - StartupMcp.MapEndpoints(endpoints); + { + StartupMcp.MapEndpoints(endpoints); } }); @@ -657,7 +654,7 @@ public void Configure(IApplicationBuilder app, Microsoft.AspNetCore.Hosting.IHos }, ContentTypeProvider = provider }); - + app.UseExceptionHandler(new ExceptionHandlerOptions { ExceptionHandler = new CustomExceptionHandlerMiddleware().Invoke, @@ -719,8 +716,9 @@ private void ConfigureSwaggerUI(IApplicationBuilder app, string baseVirtualPath) { try { - string baseVirtualPathWithSep = string.IsNullOrEmpty(baseVirtualPath) ? string.Empty: $"{baseVirtualPath.TrimStart('/')}/"; - foreach (string yaml in Directory.GetFiles(LocalPath, "*.yaml")) { + string baseVirtualPathWithSep = string.IsNullOrEmpty(baseVirtualPath) ? string.Empty : $"{baseVirtualPath.TrimStart('/')}/"; + foreach (string yaml in Directory.GetFiles(LocalPath, "*.yaml")) + { FileInfo finfo = new FileInfo(yaml); app.UseSwaggerUI(options =>