Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,15 @@ public override Task<HTTPResponse> HandleRequest(HTTPRequest request, Grpc.Core.

### Logging

Plugins that extend `BasePlugin` have access to an `ILogger` instance via the `Logger` property. This allows plugins to emit structured logs that appear in the host application's output.
Plugins that extend `BasePlugin` have access to an `ILogger` instance via the `Logger` property. The SDK automatically provides a default logger that outputs logs in a format compatible with mcpd's log level inference:

```
[INFO] Plugin server listening on unix /var/...
[WARN] Rate limit exceeded
[ERROR] Failed to process request
```

This format allows mcpd to properly categorize log messages by severity.

```csharp
using Microsoft.Extensions.Logging;
Expand All @@ -142,13 +150,13 @@ public class MyPlugin : BasePlugin
{
public override Task<Empty> Configure(PluginConfig request, Grpc.Core.ServerCallContext context)
{
Logger?.LogInformation("Plugin configured with {Count} settings", request.Settings.Count);
Logger.LogInformation("Plugin configured with {Count} settings", request.Settings.Count);
return Task.FromResult(new Empty());
}

public override Task<HTTPResponse> HandleRequest(HTTPRequest request, Grpc.Core.ServerCallContext context)
{
Logger?.LogInformation("Processing request: {Method} {Path}", request.Method, request.Path);
Logger.LogInformation("Processing request: {Method} {Path}", request.Method, request.Path);

// Your plugin logic here.

Expand All @@ -157,7 +165,20 @@ public class MyPlugin : BasePlugin
}
```

The SDK automatically suppresses ASP.NET Core framework logs at the Info level to reduce noise. Plugin logs at Info level and above will appear normally in the host application's output.
For advanced scenarios, you can provide your own logger:

```csharp
var loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddJsonConsole();
builder.SetMinimumLevel(LogLevel.Debug);
});
var logger = loggerFactory.CreateLogger<MyPlugin>();

return await PluginServer.Serve<MyPlugin>(args, logger);
```

The SDK automatically suppresses ASP.NET Core framework logs at the Info level to reduce noise.

## Running Your Plugin

Expand Down
5 changes: 3 additions & 2 deletions src/MozillaAI.Mcpd.Plugins.Sdk/BasePlugin.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

namespace MozillaAI.Mcpd.Plugins.V1;

Expand Down Expand Up @@ -46,9 +47,9 @@ namespace MozillaAI.Mcpd.Plugins.V1;
public class BasePlugin : Plugin.PluginBase
{
/// <summary>
/// Logger instance for the plugin. Available after SetLogger is called by the SDK.
/// Logger instance for the plugin.
/// </summary>
protected ILogger? Logger { get; private set; }
protected ILogger Logger { get; private set; } = NullLogger.Instance;

/// <summary>
/// SetLogger is called by the SDK to provide a logger to the plugin.
Expand Down
48 changes: 48 additions & 0 deletions src/MozillaAI.Mcpd.Plugins.Sdk/McpdLogFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Console;

namespace MozillaAI.Mcpd.Plugins.V1;

/// <summary>
/// Console formatter that outputs log messages in a format compatible with mcpd's log inference.
/// </summary>
internal sealed class McpdLogFormatter : ConsoleFormatter
{
public McpdLogFormatter() : base("mcpd")
{
}

public override void Write<TState>(in LogEntry<TState> logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter)
{
var message = logEntry.Formatter(logEntry.State, logEntry.Exception);
if (string.IsNullOrEmpty(message))
{
return;
}

var levelString = GetLevelString(logEntry.LogLevel);
textWriter.Write(levelString);
textWriter.Write(' ');
textWriter.WriteLine(message);

if (logEntry.Exception != null)
{
textWriter.WriteLine(logEntry.Exception.ToString());
}
}

private static string GetLevelString(LogLevel logLevel)
{
return logLevel switch
{
LogLevel.Trace => "[TRACE]",
LogLevel.Debug => "[DEBUG]",
LogLevel.Information => "[INFO]",
LogLevel.Warning => "[WARN]",
LogLevel.Error => "[ERROR]",
LogLevel.Critical => "[ERROR]",
_ => "[INFO]"
};
}
}
52 changes: 31 additions & 21 deletions src/MozillaAI.Mcpd.Plugins.Sdk/PluginServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Console;

namespace MozillaAI.Mcpd.Plugins.V1;

Expand All @@ -31,20 +31,38 @@ public static class PluginServer
/// </summary>
/// <typeparam name="T">The plugin implementation type that inherits from Plugin.PluginBase.</typeparam>
/// <param name="args">Command-line arguments.</param>
/// <param name="logger">Optional logger instance. If null, a default console logger is created.</param>
/// <returns>Exit code (0 for success, 1 for error).</returns>
public static async Task<int> Serve<T>(string[] args) where T : Plugin.PluginBase, new()
public static async Task<int> Serve<T>(string[] args, ILogger? logger = null) where T : Plugin.PluginBase, new()
{
return await Serve(new T(), args);
return await Serve(new T(), args, logger);
}

/// <summary>
/// Starts a gRPC server for the specified plugin instance.
/// </summary>
/// <param name="implementation">The plugin implementation instance.</param>
/// <param name="args">Command-line arguments.</param>
/// <param name="logger">Optional logger instance. If null, a default console logger is created.</param>
/// <returns>Exit code (0 for success, 1 for error).</returns>
public static async Task<int> Serve(Plugin.PluginBase implementation, string[] args)
public static async Task<int> Serve(Plugin.PluginBase implementation, string[] args, ILogger? logger = null)
{
// Create default console logger if none provided.
logger ??= LoggerFactory.Create(builder =>
{
builder.AddConsole(options => options.FormatterName = "mcpd");
builder.AddConsoleFormatter<McpdLogFormatter, ConsoleFormatterOptions>();
builder.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
builder.AddFilter("Microsoft.Extensions", LogLevel.Warning);
builder.AddFilter("Microsoft.Hosting", LogLevel.Warning);
}).CreateLogger(implementation.GetType());

// Provide logger to BasePlugin implementations.
if (implementation is BasePlugin basePlugin)
{
basePlugin.SetLogger(logger);
}

var addressOption = new Option<string>(
name: "--address",
description: "gRPC address (socket path for unix, host:port for tcp)")
Expand All @@ -63,27 +81,25 @@ public static async Task<int> Serve(Plugin.PluginBase implementation, string[] a
networkOption
};

rootCommand.SetHandler(async (address, network) =>
{
await RunServer(implementation, address, network);
}, addressOption, networkOption);
rootCommand.SetHandler(async (address, network) => await RunServer(implementation, address, network, logger), addressOption, networkOption);

return await rootCommand.InvokeAsync(args);
}

private static async Task RunServer(Plugin.PluginBase implementation, string address, string network)
private static async Task RunServer(Plugin.PluginBase implementation, string address, string network, ILogger logger)
{
var builder = WebApplication.CreateSlimBuilder();

// Suppress ASP.NET Core diagnostic logs but allow plugin logs.
// Suppress ASP.NET Core diagnostic logs.
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddConsole(options => options.FormatterName = "mcpd");
builder.Logging.AddConsoleFormatter<McpdLogFormatter, ConsoleFormatterOptions>();
builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
builder.Logging.AddFilter("Microsoft.Extensions", LogLevel.Warning);
builder.Logging.AddFilter("Microsoft.Hosting", LogLevel.Warning);

// Configure Kestrel.
builder.WebHost.ConfigureKestrel((context, serverOptions) =>
builder.WebHost.ConfigureKestrel((_, serverOptions) =>
{
if (network.Equals("unix", StringComparison.OrdinalIgnoreCase))
{
Expand Down Expand Up @@ -129,7 +145,8 @@ private static async Task RunServer(Plugin.PluginBase implementation, string add
// Map gRPC service.
app.MapGrpcService<PluginServiceAdapter>();

Console.WriteLine($"Plugin server listening on {network} {address}");
logger.LogInformation("Plugin server listening on {Network} {Address}", network, address);


try
{
Expand All @@ -152,16 +169,9 @@ private class PluginServiceAdapter : Plugin.PluginBase
{
private readonly Plugin.PluginBase _implementation;

public PluginServiceAdapter(Plugin.PluginBase implementation, ILoggerFactory loggerFactory)
public PluginServiceAdapter(Plugin.PluginBase implementation)
{
_implementation = implementation;

// Provide logger to BasePlugin implementations.
if (_implementation is BasePlugin basePlugin)
{
var logger = loggerFactory.CreateLogger(_implementation.GetType());
basePlugin.SetLogger(logger);
}
}

public override Task<Google.Protobuf.WellKnownTypes.Empty> Configure(PluginConfig request, Grpc.Core.ServerCallContext context)
Expand Down