forked from admin/VolcanicAgenticCore
feat(mcp): Playwright preset and server resolution
Spec: the preset defaults --browser to chromium and drops --user-data-dir with --isolated; source order and the send-less prompt line are spelled out.
This commit is contained in:
@@ -53,6 +53,9 @@ public sealed class McpOptions
|
||||
foreach (var (name, server) in Servers) errors.AddRange(server.Validate(name));
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>The startup log line: <c>MCP: playwright enabled (headless, chromium)</c> or how to enable it.</summary>
|
||||
public string DescribePlaywright() => PlaywrightPreset.Describe(Playwright);
|
||||
}
|
||||
|
||||
/// <summary>One stdio MCP server. <c>${workspace}</c> expands in command, args, cwd and env; <c>${env:NAME}</c> in env values.</summary>
|
||||
@@ -103,7 +106,7 @@ public sealed class PlaywrightOptions
|
||||
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>
|
||||
/// <summary>npm package spec used with npx. Defaults to <see cref="PlaywrightPreset.DefaultPackage"/>.</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; }
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Mcp;
|
||||
|
||||
/// <summary>One configured server as a session sees it: config, prompt section and whether it can run.</summary>
|
||||
internal sealed record McpServerEntry
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required McpServerConfig Config { get; init; }
|
||||
/// <summary>Key in <c>SystemPromptOptions.Sections</c>.</summary>
|
||||
public required string SectionKey { get; init; }
|
||||
/// <summary>The prompt section given whether a <c>send</c> tool exists; null for none.</summary>
|
||||
public required Func<bool, string?> PromptSection { get; init; }
|
||||
public bool Disabled { get; init; }
|
||||
public string? DisabledHint { get; init; }
|
||||
/// <summary>Validation errors; a server with a problem is listed but never started.</summary>
|
||||
public string? Problem { get; init; }
|
||||
|
||||
public static McpServerEntry For(string name, McpServerConfig config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
var errors = config.Validate(name);
|
||||
return new()
|
||||
{
|
||||
Name = name,
|
||||
Config = config,
|
||||
SectionKey = name,
|
||||
PromptSection = _ => config.PromptSection,
|
||||
Disabled = !config.Enabled,
|
||||
Problem = errors.Count == 0 ? null : string.Join(" ", errors),
|
||||
};
|
||||
}
|
||||
|
||||
public static McpServerEntry Preset(PlaywrightOptions options) => new()
|
||||
{
|
||||
Name = PlaywrightPreset.ServerName,
|
||||
Config = PlaywrightPreset.ToServer(options),
|
||||
SectionKey = PlaywrightPreset.SectionKey,
|
||||
PromptSection = hasSend => PlaywrightPreset.PromptSection(hasSend),
|
||||
};
|
||||
|
||||
public static McpServerEntry DisabledPreset() => new()
|
||||
{
|
||||
Name = PlaywrightPreset.ServerName,
|
||||
Config = new McpServerConfig(),
|
||||
SectionKey = PlaywrightPreset.SectionKey,
|
||||
PromptSection = _ => null,
|
||||
Disabled = true,
|
||||
DisabledHint = "set PLAYWRIGHT_ENABLED=true",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Merges the configuration sources. The first source wins a server name.</summary>
|
||||
internal static class McpServerSet
|
||||
{
|
||||
/// <summary>
|
||||
/// Host servers in configuration order, then the Playwright preset when enabled, then the user settings. A taken
|
||||
/// name is skipped with a log line. A disabled preset is listed last, only while <c>playwright</c> is still free.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<McpServerEntry> Resolve(McpOptions options, IEnumerable<KeyValuePair<string, McpServerConfig>> userServers, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(userServers);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
var entries = new List<McpServerEntry>();
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var (name, config) in options.Servers)
|
||||
if (names.Add(name)) entries.Add(McpServerEntry.For(name, config));
|
||||
|
||||
if (options.Playwright.IsEnabled)
|
||||
{
|
||||
if (names.Add(PlaywrightPreset.ServerName)) entries.Add(McpServerEntry.Preset(options.Playwright));
|
||||
else logger.LogWarning("MCP server 'playwright' is configured explicitly; the Playwright preset is ignored.");
|
||||
}
|
||||
|
||||
foreach (var (name, config) in userServers)
|
||||
{
|
||||
if (names.Add(name)) entries.Add(McpServerEntry.For(name, config));
|
||||
else logger.LogInformation("MCP server '{Name}' in the user settings is ignored: the host configuration defines it.", name);
|
||||
}
|
||||
|
||||
if (names.Add(PlaywrightPreset.ServerName)) entries.Add(McpServerEntry.DisabledPreset());
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
namespace Volcanic.AgenticCore.Extensions.Mcp;
|
||||
|
||||
/// <summary>The <c>@playwright/mcp</c> preset: a server named <c>playwright</c> whose output and profile live in the chat workspace.</summary>
|
||||
public static class PlaywrightPreset
|
||||
{
|
||||
public const string ServerName = "playwright";
|
||||
/// <summary>The single source of truth for the version. The Dockerfile's <c>PLAYWRIGHT_MCP_VERSION</c> must match it.</summary>
|
||||
public const string DefaultPackage = "@playwright/mcp@0.0.82";
|
||||
/// <summary>Without <c>--browser</c>, @playwright/mcp uses the Chrome channel, which the deployment doesn't install.</summary>
|
||||
public const string DefaultBrowser = "chromium";
|
||||
/// <summary>Key of the preset's section in the system prompt.</summary>
|
||||
public const string SectionKey = "browser";
|
||||
|
||||
/// <summary>The exact version in <see cref="DefaultPackage"/>.</summary>
|
||||
public static string PackageVersion => DefaultPackage[(DefaultPackage.LastIndexOf('@') + 1)..];
|
||||
|
||||
/// <summary>The server entry the preset expands into.</summary>
|
||||
public static McpServerConfig ToServer(PlaywrightOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var args = new List<string>();
|
||||
var command = options.Command;
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
command = "npx";
|
||||
args.Add("-y");
|
||||
args.Add(string.IsNullOrWhiteSpace(options.Package) ? DefaultPackage : options.Package);
|
||||
}
|
||||
if (options.IsHeadless) args.Add("--headless");
|
||||
args.Add("--browser");
|
||||
args.Add(Browser(options));
|
||||
args.Add("--output-dir");
|
||||
args.Add("${workspace}/browser");
|
||||
var extra = options.ExtraArgs ?? [];
|
||||
if (!extra.Contains("--isolated"))
|
||||
{
|
||||
args.Add("--user-data-dir");
|
||||
args.Add("${workspace}/.volcanic/browser-profile");
|
||||
}
|
||||
args.AddRange(extra);
|
||||
return new McpServerConfig
|
||||
{
|
||||
Command = command,
|
||||
Args = args,
|
||||
Cwd = "${workspace}",
|
||||
StartupTimeoutSeconds = 120,
|
||||
CallTimeoutSeconds = 120,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The startup log line: whether the preset is on, and with which mode and browser.</summary>
|
||||
public static string Describe(PlaywrightOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return options.IsEnabled
|
||||
? $"MCP: playwright enabled ({(options.IsHeadless ? "headless" : "headed")}, {Browser(options)})"
|
||||
: "MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true)";
|
||||
}
|
||||
|
||||
/// <summary>The <c>browser</c> prompt section. The <c>send</c> line appears only when a <c>send</c> tool is registered.</summary>
|
||||
public static string PromptSection(bool hasSend)
|
||||
{
|
||||
string[] files = hasSend
|
||||
?
|
||||
[
|
||||
"- To give the user a screenshot, save it with `filename: \"browser/<name>.png\"` and then call `send` with that",
|
||||
" path. Files the browser saves (PDFs, downloads) are under browser/ too.",
|
||||
]
|
||||
:
|
||||
[
|
||||
"- Files the browser saves (screenshots with a `filename` such as `browser/<name>.png`, PDFs, downloads) are",
|
||||
" under browser/ in the workspace.",
|
||||
];
|
||||
string[] lines =
|
||||
[
|
||||
"## Browser",
|
||||
"- browser_* tools drive a real browser. Use browser_snapshot (an accessibility tree with element refs) to read a",
|
||||
" page and find refs for click/type; it is cheaper and more reliable than screenshots.",
|
||||
"- Take a screenshot without `filename` only when you need to see the visual layout yourself; the image comes back",
|
||||
" to you.",
|
||||
.. files,
|
||||
"- Close the browser (browser_close) when a task is finished. Browser state may be reset after a long pause.",
|
||||
"- Don't enter passwords or payment details unless the user explicitly asks for that specific site.",
|
||||
];
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
private static string Browser(PlaywrightOptions options) =>
|
||||
string.IsNullOrWhiteSpace(options.Browser) ? DefaultBrowser : options.Browser;
|
||||
}
|
||||
Reference in New Issue
Block a user