forked from admin/VolcanicAgenticCore
feat(mcp): extension project and configuration
McpOptions/McpServerConfig/PlaywrightOptions with ApplyEnvironment (PLAYWRIGHT_* variables, quote-aware ExtraArgs split) and Validate; ShellWords; mcpServers from the user settings.json. New project in the solution and referenced by the extension tests.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
<Project Path="extensions/Volcanic.AgenticCore.Extensions.Anthropic/Volcanic.AgenticCore.Extensions.Anthropic.csproj" />
|
||||
<Project Path="extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj" />
|
||||
<Project Path="extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
|
||||
<Project Path="extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
|
||||
<Project Path="extensions/Volcanic.AgenticCore.Extensions.OpenAI/Volcanic.AgenticCore.Extensions.OpenAI.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/hosts/">
|
||||
|
||||
113
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpOptions.cs
Normal file
113
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpOptions.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Mcp;
|
||||
|
||||
/// <summary>MCP client configuration: the <c>Mcp</c> section of the host configuration, plus the <c>PLAYWRIGHT_*</c> variables.</summary>
|
||||
public sealed class McpOptions
|
||||
{
|
||||
public const string SectionName = "Mcp";
|
||||
|
||||
private readonly List<string> _environmentErrors = [];
|
||||
|
||||
/// <summary>Servers by name. Enumeration order is the configured order; the first server wins a tool name.</summary>
|
||||
public Dictionary<string, McpServerConfig> Servers { get; set; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>The Playwright preset. Expands into a server named <c>playwright</c> when enabled.</summary>
|
||||
public PlaywrightOptions Playwright { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Fills unset preset values from <c>PLAYWRIGHT_ENABLED</c>, <c>PLAYWRIGHT_HEADLESS</c>, <c>PLAYWRIGHT_BROWSER</c>,
|
||||
/// <c>PLAYWRIGHT_EXTRA_ARGS</c> (shell-style split) and <c>PLAYWRIGHT_COMMAND</c>. Configured values win; an empty
|
||||
/// variable counts as unset. Problems are reported by <see cref="Validate"/>.
|
||||
/// </summary>
|
||||
public void ApplyEnvironment(Func<string, string?> getEnv)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(getEnv);
|
||||
string? Get(string name) => getEnv(name) is { Length: > 0 } value ? value : null;
|
||||
var p = Playwright;
|
||||
if (p.Enabled is null && Get("PLAYWRIGHT_ENABLED") is { } enabled) p.Enabled = ParseBool("PLAYWRIGHT_ENABLED", enabled);
|
||||
if (p.Headless is null && Get("PLAYWRIGHT_HEADLESS") is { } headless) p.Headless = ParseBool("PLAYWRIGHT_HEADLESS", headless);
|
||||
p.Browser ??= Get("PLAYWRIGHT_BROWSER");
|
||||
p.Command ??= Get("PLAYWRIGHT_COMMAND");
|
||||
if (p.ExtraArgs is not { Count: > 0 } && Get("PLAYWRIGHT_EXTRA_ARGS") is { } extra)
|
||||
{
|
||||
try { p.ExtraArgs = [.. ShellWords.Split(extra)]; }
|
||||
catch (FormatException ex) { _environmentErrors.Add($"PLAYWRIGHT_EXTRA_ARGS: {ex.Message}."); }
|
||||
}
|
||||
}
|
||||
|
||||
private bool? ParseBool(string name, string value)
|
||||
{
|
||||
if (bool.TryParse(value, out var parsed)) return parsed;
|
||||
if (value == "1") return true;
|
||||
if (value == "0") return false;
|
||||
_environmentErrors.Add($"{name} must be true or false, got '{value}'.");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Configuration errors: environment parsing problems and every server's own errors.</summary>
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
var errors = new List<string>(_environmentErrors);
|
||||
foreach (var (name, server) in Servers) errors.AddRange(server.Validate(name));
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One stdio MCP server. <c>${workspace}</c> expands in command, args, cwd and env; <c>${env:NAME}</c> in env values.</summary>
|
||||
public sealed partial class McpServerConfig
|
||||
{
|
||||
public string Command { get; set; } = "";
|
||||
public List<string> Args { get; set; } = [];
|
||||
/// <summary>Applied over the filtered environment and never filtered. A null value removes the variable.</summary>
|
||||
public Dictionary<string, string?> Env { get; set; } = new(StringComparer.Ordinal);
|
||||
/// <summary>Working directory of the session process. Defaults to <c>${workspace}</c>.</summary>
|
||||
public string? Cwd { get; set; }
|
||||
public string? ToolPrefix { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
public bool Parallel { get; set; }
|
||||
public int StartupTimeoutSeconds { get; set; } = 60;
|
||||
public int CallTimeoutSeconds { get; set; } = 120;
|
||||
/// <summary>0 means an idle process is never stopped.</summary>
|
||||
public int IdleTimeoutSeconds { get; set; } = 1800;
|
||||
public LogLevel StderrLevel { get; set; } = LogLevel.Warning;
|
||||
/// <summary>Added to the system prompt while any of this server's tools is active.</summary>
|
||||
public string? PromptSection { get; set; }
|
||||
|
||||
public IReadOnlyList<string> Validate(string name)
|
||||
{
|
||||
var where = $"Mcp:Servers:{name}";
|
||||
var errors = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(name)) errors.Add("Mcp:Servers: a server name is empty.");
|
||||
if (string.IsNullOrWhiteSpace(Command)) errors.Add($"{where}: command is required.");
|
||||
if (StartupTimeoutSeconds <= 0) errors.Add($"{where}: startupTimeoutSeconds must be positive.");
|
||||
if (CallTimeoutSeconds <= 0) errors.Add($"{where}: callTimeoutSeconds must be positive.");
|
||||
if (IdleTimeoutSeconds < 0) errors.Add($"{where}: idleTimeoutSeconds must be 0 (never) or positive.");
|
||||
if (ToolPrefix is { Length: > 0 } prefix)
|
||||
{
|
||||
if (!PrefixPattern().IsMatch(prefix)) errors.Add($"{where}: toolPrefix may only contain letters, digits, '_' and '-'.");
|
||||
else if (prefix.StartsWith("mcp__", StringComparison.OrdinalIgnoreCase)) errors.Add($"{where}: toolPrefix must not start with mcp__.");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
[GeneratedRegex("^[A-Za-z0-9_-]*$")]
|
||||
private static partial Regex PrefixPattern();
|
||||
}
|
||||
|
||||
/// <summary>The Playwright preset. Null means "not configured" so the environment can fill it in.</summary>
|
||||
public sealed class PlaywrightOptions
|
||||
{
|
||||
public bool? Enabled { get; set; }
|
||||
public bool? Headless { get; set; }
|
||||
public string? Browser { get; set; }
|
||||
public List<string>? ExtraArgs { get; set; }
|
||||
/// <summary>npm package spec used with npx. Defaults to <c>PlaywrightPreset.DefaultPackage</c>.</summary>
|
||||
public string? Package { get; set; }
|
||||
/// <summary>Run this binary instead of <c>npx -y <package></c> (e.g. the globally installed <c>playwright-mcp</c>).</summary>
|
||||
public string? Command { get; set; }
|
||||
|
||||
public bool IsEnabled => Enabled ?? false;
|
||||
public bool IsHeadless => Headless ?? true;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Mcp;
|
||||
|
||||
/// <summary>Reads <c>"mcpServers"</c> from <c><agent dir>/settings.json</c> (the same file that lists extensions).</summary>
|
||||
internal static class McpUserSettings
|
||||
{
|
||||
public const string AgentDirVariable = "VOLCANIC_AGENT_DIR";
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary><c>VOLCANIC_AGENT_DIR</c>, else <c>~/.volcanic/agent</c>.</summary>
|
||||
public static string AgentDir(Func<string, string?> getEnv) =>
|
||||
getEnv(AgentDirVariable) is { Length: > 0 } dir
|
||||
? dir
|
||||
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent");
|
||||
|
||||
/// <summary>Servers in document order. A missing file or section is empty; a malformed file is logged and ignored.</summary>
|
||||
public static IReadOnlyList<KeyValuePair<string, McpServerConfig>> Load(string agentDir, ILogger logger)
|
||||
{
|
||||
var path = Path.Combine(agentDir, "settings.json");
|
||||
if (!File.Exists(path)) return [];
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path),
|
||||
new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object
|
||||
|| !doc.RootElement.TryGetProperty("mcpServers", out var servers)
|
||||
|| servers.ValueKind != JsonValueKind.Object) return [];
|
||||
var result = new List<KeyValuePair<string, McpServerConfig>>();
|
||||
foreach (var server in servers.EnumerateObject())
|
||||
result.Add(new(server.Name, server.Value.Deserialize<McpServerConfig>(Json) ?? new McpServerConfig()));
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or NotSupportedException)
|
||||
{
|
||||
logger.LogWarning("Ignoring mcpServers in {Path}: {Message}", path, ex.Message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
43
extensions/Volcanic.AgenticCore.Extensions.Mcp/ShellWords.cs
Normal file
43
extensions/Volcanic.AgenticCore.Extensions.Mcp/ShellWords.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Mcp;
|
||||
|
||||
/// <summary>Splits a command-line fragment into argv the way a POSIX shell would, without expansion.</summary>
|
||||
internal static class ShellWords
|
||||
{
|
||||
/// <summary>Whitespace separates words; '…' is literal; "…" allows \" and \\. Backslashes elsewhere are kept (Windows paths).</summary>
|
||||
public static IReadOnlyList<string> Split(string text)
|
||||
{
|
||||
var words = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
var inWord = false;
|
||||
char? quote = null;
|
||||
for (var i = 0; i < text.Length; i++)
|
||||
{
|
||||
var c = text[i];
|
||||
if (quote == '\'')
|
||||
{
|
||||
if (c == '\'') quote = null; else current.Append(c);
|
||||
continue;
|
||||
}
|
||||
if (quote == '"')
|
||||
{
|
||||
if (c == '"') quote = null;
|
||||
else if (c == '\\' && i + 1 < text.Length && text[i + 1] is '"' or '\\') current.Append(text[++i]);
|
||||
else current.Append(c);
|
||||
continue;
|
||||
}
|
||||
if (char.IsWhiteSpace(c))
|
||||
{
|
||||
if (inWord) { words.Add(current.ToString()); current.Clear(); inWord = false; }
|
||||
continue;
|
||||
}
|
||||
inWord = true;
|
||||
if (c is '"' or '\'') quote = c;
|
||||
else current.Append(c);
|
||||
}
|
||||
if (quote is { } open) throw new FormatException($"unterminated {open} quote");
|
||||
if (inWord) words.Add(current.ToString());
|
||||
return words;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<Description>MCP client extension: stdio MCP servers as agent tools, with a Playwright browser preset.</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" />
|
||||
<PackageReference Include="ModelContextProtocol.Core" Version="2.2.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Volcanic.AgenticCore.Extensions.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,182 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Extensions.Mcp;
|
||||
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
|
||||
|
||||
public class McpOptionsTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), "volcanic-mcp-options", Guid.NewGuid().ToString("N"));
|
||||
|
||||
public McpOptionsTests() => Directory.CreateDirectory(_dir);
|
||||
public void Dispose() { try { Directory.Delete(_dir, true); } catch (IOException) { } }
|
||||
|
||||
private static Func<string, string?> Env(params (string Name, string Value)[] vars)
|
||||
{
|
||||
var map = vars.ToDictionary(v => v.Name, v => v.Value);
|
||||
return name => map.TryGetValue(name, out var value) ? value : null;
|
||||
}
|
||||
|
||||
private static McpServerConfig Valid() => new() { Command = "npx", Args = ["-y", "server"] };
|
||||
|
||||
[Fact]
|
||||
public void ApplyEnvironment_reads_the_playwright_variables()
|
||||
{
|
||||
var options = new McpOptions();
|
||||
options.ApplyEnvironment(Env(("PLAYWRIGHT_ENABLED", "true"), ("PLAYWRIGHT_HEADLESS", "false"),
|
||||
("PLAYWRIGHT_BROWSER", "firefox"), ("PLAYWRIGHT_COMMAND", "playwright-mcp"), ("PLAYWRIGHT_EXTRA_ARGS", "--no-sandbox")));
|
||||
|
||||
Assert.True(options.Playwright.IsEnabled);
|
||||
Assert.False(options.Playwright.IsHeadless);
|
||||
Assert.Equal("firefox", options.Playwright.Browser);
|
||||
Assert.Equal("playwright-mcp", options.Playwright.Command);
|
||||
Assert.Equal(["--no-sandbox"], options.Playwright.ExtraArgs!);
|
||||
Assert.Empty(options.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyEnvironment_splits_extra_args_honouring_quotes()
|
||||
{
|
||||
var options = new McpOptions();
|
||||
options.ApplyEnvironment(Env(("PLAYWRIGHT_EXTRA_ARGS", "--device \"iPhone 15\" --viewport-size '1280, 720' --isolated")));
|
||||
Assert.Equal(["--device", "iPhone 15", "--viewport-size", "1280, 720", "--isolated"], options.Playwright.ExtraArgs!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyEnvironment_config_values_win_over_the_environment()
|
||||
{
|
||||
var options = new McpOptions { Playwright = new PlaywrightOptions { Enabled = false, Headless = true, Browser = "webkit", Command = "custom", ExtraArgs = ["--a"] } };
|
||||
options.ApplyEnvironment(Env(("PLAYWRIGHT_ENABLED", "true"), ("PLAYWRIGHT_HEADLESS", "false"), ("PLAYWRIGHT_BROWSER", "firefox"),
|
||||
("PLAYWRIGHT_COMMAND", "playwright-mcp"), ("PLAYWRIGHT_EXTRA_ARGS", "--b")));
|
||||
|
||||
Assert.False(options.Playwright.IsEnabled);
|
||||
Assert.True(options.Playwright.IsHeadless);
|
||||
Assert.Equal("webkit", options.Playwright.Browser);
|
||||
Assert.Equal("custom", options.Playwright.Command);
|
||||
Assert.Equal(["--a"], options.Playwright.ExtraArgs!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyEnvironment_treats_empty_variables_as_unset()
|
||||
{
|
||||
var options = new McpOptions();
|
||||
options.ApplyEnvironment(Env(("PLAYWRIGHT_ENABLED", ""), ("PLAYWRIGHT_HEADLESS", ""), ("PLAYWRIGHT_BROWSER", ""),
|
||||
("PLAYWRIGHT_COMMAND", ""), ("PLAYWRIGHT_EXTRA_ARGS", "")));
|
||||
|
||||
Assert.False(options.Playwright.IsEnabled);
|
||||
Assert.True(options.Playwright.IsHeadless);
|
||||
Assert.Null(options.Playwright.Browser);
|
||||
Assert.Null(options.Playwright.Command);
|
||||
Assert.Null(options.Playwright.ExtraArgs);
|
||||
Assert.Empty(options.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyEnvironment_reports_an_invalid_boolean_and_an_unterminated_quote()
|
||||
{
|
||||
var options = new McpOptions();
|
||||
options.ApplyEnvironment(Env(("PLAYWRIGHT_ENABLED", "maybe"), ("PLAYWRIGHT_EXTRA_ARGS", "--device \"iPhone")));
|
||||
var errors = options.Validate();
|
||||
Assert.Contains(errors, e => e.Contains("PLAYWRIGHT_ENABLED", StringComparison.Ordinal));
|
||||
Assert.Contains(errors, e => e.Contains("PLAYWRIGHT_EXTRA_ARGS", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_rejects_an_empty_command()
|
||||
{
|
||||
var options = new McpOptions { Servers = { ["github"] = new McpServerConfig { Command = " " } } };
|
||||
var error = Assert.Single(options.Validate());
|
||||
Assert.Contains("github", error, StringComparison.Ordinal);
|
||||
Assert.Contains("command", error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 120, 1800)]
|
||||
[InlineData(-1, 120, 1800)]
|
||||
[InlineData(60, 0, 1800)]
|
||||
[InlineData(60, 120, -1)]
|
||||
public void Validate_rejects_non_positive_timeouts(int startup, int call, int idle)
|
||||
{
|
||||
var config = Valid();
|
||||
config.StartupTimeoutSeconds = startup;
|
||||
config.CallTimeoutSeconds = call;
|
||||
config.IdleTimeoutSeconds = idle;
|
||||
Assert.Single(config.Validate("s"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_accepts_zero_idle_timeout_and_an_ordinary_prefix()
|
||||
{
|
||||
var config = Valid();
|
||||
config.IdleTimeoutSeconds = 0;
|
||||
config.ToolPrefix = "gh_";
|
||||
Assert.Empty(config.Validate("s"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("gh.")]
|
||||
[InlineData("a b")]
|
||||
[InlineData("mcp__x_")]
|
||||
[InlineData("MCP__x_")]
|
||||
public void Validate_rejects_bad_prefixes(string prefix)
|
||||
{
|
||||
var config = Valid();
|
||||
config.ToolPrefix = prefix;
|
||||
Assert.Contains(config.Validate("s"), e => e.Contains("toolPrefix", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserSettings_load_mcpServers_in_document_order()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_dir, "settings.json"), """
|
||||
{
|
||||
// comments are allowed
|
||||
"extensions": [],
|
||||
"mcpServers": {
|
||||
"zeta": { "command": "z", "args": ["1"], "env": { "A": "x", "REMOVED": null }, "stderrLevel": "Information" },
|
||||
"alpha": { "command": "a", "toolPrefix": "al_", "parallel": true, "idleTimeoutSeconds": 0 },
|
||||
}
|
||||
}
|
||||
""");
|
||||
var servers = McpUserSettings.Load(_dir, new RecordingLogger());
|
||||
|
||||
Assert.Equal(["zeta", "alpha"], servers.Select(s => s.Key));
|
||||
var zeta = servers[0].Value;
|
||||
Assert.Equal("z", zeta.Command);
|
||||
Assert.Equal(["1"], zeta.Args);
|
||||
Assert.Equal("x", zeta.Env["A"]);
|
||||
Assert.True(zeta.Env.ContainsKey("REMOVED"));
|
||||
Assert.Null(zeta.Env["REMOVED"]);
|
||||
Assert.Equal(LogLevel.Information, zeta.StderrLevel);
|
||||
var alpha = servers[1].Value;
|
||||
Assert.Equal("al_", alpha.ToolPrefix);
|
||||
Assert.True(alpha.Parallel);
|
||||
Assert.Equal(0, alpha.IdleTimeoutSeconds);
|
||||
Assert.Equal(60, alpha.StartupTimeoutSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserSettings_missing_file_or_section_is_empty()
|
||||
{
|
||||
Assert.Empty(McpUserSettings.Load(_dir, new RecordingLogger()));
|
||||
File.WriteAllText(Path.Combine(_dir, "settings.json"), """{ "extensions": [] }""");
|
||||
Assert.Empty(McpUserSettings.Load(_dir, new RecordingLogger()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserSettings_malformed_file_is_ignored_with_a_warning()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_dir, "settings.json"), "{ \"mcpServers\": { ");
|
||||
var logger = new RecordingLogger();
|
||||
Assert.Empty(McpUserSettings.Load(_dir, logger));
|
||||
Assert.True(logger.Has(LogLevel.Warning, "settings.json"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentDir_prefers_VOLCANIC_AGENT_DIR()
|
||||
{
|
||||
Assert.Equal(_dir, McpUserSettings.AgentDir(Env(("VOLCANIC_AGENT_DIR", _dir))));
|
||||
var fallback = McpUserSettings.AgentDir(Env());
|
||||
Assert.EndsWith(Path.Combine(".volcanic", "agent"), fallback, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
|
||||
|
||||
/// <summary>Captures formatted log lines at every level.</summary>
|
||||
public sealed class RecordingLogger : ILogger
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
public bool Has(LogLevel level, string fragment)
|
||||
{
|
||||
lock (Entries) return Entries.Any(e => e.Level == level && e.Message.Contains(fragment, StringComparison.Ordinal));
|
||||
}
|
||||
public IReadOnlyList<string> Messages(LogLevel level)
|
||||
{
|
||||
lock (Entries) return Entries.Where(e => e.Level == level).Select(e => e.Message).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Every category logs into one <see cref="RecordingLogger"/>.</summary>
|
||||
public sealed class RecordingLoggerFactory : ILoggerFactory
|
||||
{
|
||||
public RecordingLogger Logger { get; } = new();
|
||||
public ILogger CreateLogger(string categoryName) => Logger;
|
||||
public void AddProvider(ILoggerProvider provider) { }
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -21,5 +21,6 @@
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.OpenAI/Volcanic.AgenticCore.Extensions.OpenAI.csproj" />
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj" />
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user