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/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 7e11e9e7b..f6510b89d 100644 --- a/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs +++ b/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs @@ -12,6 +12,7 @@ using GeneXus.Services.OpenTelemetry; using GeneXus.Utils; using GxClasses.Web.Middleware; + using Microsoft.AspNetCore; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Builder; @@ -36,6 +37,7 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; + using StackExchange.Redis; namespace GeneXus.Application @@ -59,6 +61,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,8 +70,6 @@ public static void Main(string[] args) } - MCPTools.ServerStart(Startup.VirtualPath, Startup.LocalPath, schema); - if (port == DEFAULT_PORT) { BuildWebHost(null).Run(); @@ -167,6 +169,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,26 +280,31 @@ public void ConfigureServices(IServiceCollection services) options.SuppressXFrameOptionsHeader = true; }); } + if (Startup.IsMcp) + { + StartupMcp.AddService(services); + } + services.AddDirectoryBrowser(); if (GXUtil.CompressResponse()) { 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; }); } @@ -374,9 +382,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)) { @@ -437,17 +445,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)); + }); }); } } @@ -501,7 +509,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}"); @@ -586,11 +593,16 @@ 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); + } }); + if (log.IsCriticalEnabled && env.IsDevelopment()) { try @@ -704,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 => @@ -867,36 +880,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 {