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:
EugeneTes
2026-09-26 20:15:35 +02:00
parent e6b09fef1e
commit 5b7d593427
6 changed files with 362 additions and 3 deletions

View File

@@ -122,6 +122,9 @@ two meet only in tool naming (see "Names").
them all through).
- Validation (`McpOptions.Validate()`) rejects empty commands, non-positive timeouts, and prefixes that don't match
`[A-Za-z0-9_-]*` or that start with `mcp__`.
- Order: `Mcp:Servers` in configuration order, then the Playwright preset (skipped with a warning when a server is
explicitly named `playwright`), then the user settings. The first source wins on a server name. `/mcp` lists a
disabled preset as `playwright: disabled (set PLAYWRIGHT_ENABLED=true)`.
- The extension has two constructors, `McpExtension(McpOptions)` for DI and a parameterless one that reads only
the environment and user settings, so a dropped-in DLL works without host code. The agent dir is
`VOLCANIC_AGENT_DIR` or `~/.volcanic/agent`.
@@ -133,7 +136,7 @@ When enabled, it expands to server `playwright`:
```
command: npx (or Playwright:Command / PLAYWRIGHT_COMMAND, e.g. the globally installed `playwright-mcp`)
args: -y @playwright/mcp@<pinned> (only with npx; Playwright:Package overrides the spec)
[--headless] [--browser <b>]
[--headless] --browser <b, default chromium>
--output-dir ${workspace}/browser
--user-data-dir ${workspace}/.volcanic/browser-profile
<ExtraArgs...>
@@ -150,6 +153,9 @@ callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads
(hence the 120 s startup timeout for the first download). The Docker image preinstalls the pinned package globally
and compose sets `PLAYWRIGHT_COMMAND=playwright-mcp`, so a container never downloads anything and the first chat
doesn't stall.
- Without `--browser`, `@playwright/mcp` uses the Chrome channel, which the deployment doesn't install (`playwright
install chromium` provides Chromium). The preset therefore passes `--browser chromium` unless `Browser` is set.
With `--isolated` in `ExtraArgs` the preset leaves out `--user-data-dir`.
- **Chromium sandbox.** The preset doesn't add `--no-sandbox` itself. Chromium's sandbox needs user namespaces, which
Docker's default seccomp profile blocks for a non-root user, so the Docker compose file sets
`PLAYWRIGHT_EXTRA_ARGS=--no-sandbox` and the container is the isolation boundary. Bare hosts keep the sandbox.
@@ -181,7 +187,9 @@ callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads
```
The `send` line is included only when a tool named `send` is registered (checked at prompt time through
`GetAllTools()`), so the preset still reads correctly on hosts without attachments.
`GetAllTools()`), so the preset still reads correctly on hosts without attachments. Without `send`, that bullet
reads: "Files the browser saves (screenshots with a `filename` such as `browser/<name>.png`, PDFs, downloads) are
under browser/ in the workspace."
### 3. Catalogue — `McpCatalogCache`

View File

@@ -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 &lt;package&gt;</c> (e.g. the globally installed <c>playwright-mcp</c>).</summary>
public string? Command { get; set; }

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,82 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpServerSetTests
{
private static McpServerConfig Server(string command) => new() { Command = command };
[Fact]
public void Host_servers_then_the_preset_then_user_servers()
{
var options = new McpOptions { Servers = { ["b"] = Server("b"), ["a"] = Server("a") }, Playwright = new PlaywrightOptions { Enabled = true } };
var entries = McpServerSet.Resolve(options, [new("user", Server("u"))], new RecordingLogger());
Assert.Equal(["b", "a", "playwright", "user"], entries.Select(e => e.Name));
var preset = entries[2];
Assert.Equal("browser", preset.SectionKey);
Assert.Equal("npx", preset.Config.Command);
Assert.False(preset.Disabled);
}
[Fact]
public void The_first_source_wins_on_a_server_name()
{
var options = new McpOptions { Servers = { ["github"] = Server("host") } };
var logger = new RecordingLogger();
var entries = McpServerSet.Resolve(options, [new("github", Server("user")), new("extra", Server("x"))], logger);
Assert.Equal("host", entries.Single(e => e.Name == "github").Config.Command);
Assert.Contains(entries, e => e.Name == "extra");
Assert.True(logger.Has(LogLevel.Information, "github"));
}
[Fact]
public void An_explicit_playwright_server_replaces_the_preset()
{
var options = new McpOptions { Servers = { ["playwright"] = Server("mine") }, Playwright = new PlaywrightOptions { Enabled = true } };
var logger = new RecordingLogger();
var entry = Assert.Single(McpServerSet.Resolve(options, [], logger));
Assert.Equal("mine", entry.Config.Command);
Assert.Equal("playwright", entry.SectionKey);
Assert.True(logger.Has(LogLevel.Warning, "preset"));
}
[Fact]
public void Disabled_preset_is_listed_last_when_its_name_is_free()
{
var entries = McpServerSet.Resolve(new McpOptions(), [new("user", Server("u"))], new RecordingLogger());
Assert.Equal(["user", "playwright"], entries.Select(e => e.Name));
Assert.True(entries[1].Disabled);
Assert.Equal("set PLAYWRIGHT_ENABLED=true", entries[1].DisabledHint);
var taken = McpServerSet.Resolve(new McpOptions(), [new("playwright", Server("u"))], new RecordingLogger());
Assert.False(Assert.Single(taken).Disabled);
}
[Fact]
public void Disabled_and_invalid_servers_are_listed_with_their_state()
{
var disabled = Server("d");
disabled.Enabled = false;
var options = new McpOptions { Servers = { ["off"] = disabled, ["bad"] = Server("") } };
var entries = McpServerSet.Resolve(options, [], new RecordingLogger());
Assert.True(entries.Single(e => e.Name == "off").Disabled);
Assert.Contains("command is required", entries.Single(e => e.Name == "bad").Problem, StringComparison.Ordinal);
}
[Fact]
public void Generic_prompt_section_comes_from_the_config()
{
var config = Server("x");
config.PromptSection = "Use x wisely.";
var entry = McpServerEntry.For("x", config);
Assert.Equal("x", entry.SectionKey);
Assert.Equal("Use x wisely.", entry.PromptSection(true));
Assert.Equal(PlaywrightPreset.PromptSection(false), McpServerEntry.Preset(new PlaywrightOptions { Enabled = true }).PromptSection(false));
}
}

View File

@@ -0,0 +1,91 @@
using System.Text.RegularExpressions;
using Volcanic.AgenticCore.Extensions.Mcp;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class PlaywrightPresetTests
{
[Fact]
public void Default_argv_runs_the_pinned_package_headless_with_chromium_and_workspace_paths()
{
var server = PlaywrightPreset.ToServer(new PlaywrightOptions { Enabled = true });
Assert.Equal("npx", server.Command);
Assert.Equal(
["-y", PlaywrightPreset.DefaultPackage, "--headless", "--browser", "chromium",
"--output-dir", "${workspace}/browser", "--user-data-dir", "${workspace}/.volcanic/browser-profile"],
server.Args);
Assert.Equal("${workspace}", server.Cwd);
Assert.Null(server.ToolPrefix);
Assert.Equal(120, server.StartupTimeoutSeconds);
Assert.Equal(120, server.CallTimeoutSeconds);
Assert.Empty(server.Env);
}
[Fact]
public void Headed_browser_and_extra_args_are_passed_through()
{
var server = PlaywrightPreset.ToServer(new PlaywrightOptions { Enabled = true, Headless = false, Browser = "firefox", ExtraArgs = ["--no-sandbox", "--device", "iPhone 15"] });
Assert.DoesNotContain("--headless", server.Args);
Assert.Equal("firefox", server.Args[server.Args.IndexOf("--browser") + 1]);
Assert.Equal(["--no-sandbox", "--device", "iPhone 15"], server.Args.TakeLast(3));
}
[Fact]
public void Isolated_extra_arg_drops_the_profile_directory()
{
var server = PlaywrightPreset.ToServer(new PlaywrightOptions { Enabled = true, ExtraArgs = ["--isolated"] });
Assert.DoesNotContain("--user-data-dir", server.Args);
Assert.Contains("--isolated", server.Args);
Assert.Contains("--output-dir", server.Args);
}
[Fact]
public void Package_override_replaces_the_pinned_spec()
{
var server = PlaywrightPreset.ToServer(new PlaywrightOptions { Enabled = true, Package = "@playwright/mcp@0.0.1" });
Assert.Equal(["-y", "@playwright/mcp@0.0.1"], server.Args.Take(2));
}
[Fact]
public void Command_runs_the_binary_directly_without_the_package()
{
var server = PlaywrightPreset.ToServer(new PlaywrightOptions { Enabled = true, Command = "playwright-mcp", Package = "ignored@1.0.0" });
Assert.Equal("playwright-mcp", server.Command);
Assert.DoesNotContain("-y", server.Args);
Assert.DoesNotContain(server.Args, a => a.Contains('@'));
Assert.Equal("--headless", server.Args[0]);
}
[Fact]
public void Default_package_is_pinned_to_an_exact_version()
{
Assert.Matches(new Regex(@"^@playwright/mcp@\d+\.\d+\.\d+$"), PlaywrightPreset.DefaultPackage);
Assert.Equal("0.0.82", PlaywrightPreset.PackageVersion);
}
[Fact]
public void Describe_reports_enabled_and_disabled()
{
Assert.Equal("MCP: playwright enabled (headless, chromium)", PlaywrightPreset.Describe(new PlaywrightOptions { Enabled = true }));
Assert.Equal("MCP: playwright enabled (headed, firefox)", PlaywrightPreset.Describe(new PlaywrightOptions { Enabled = true, Headless = false, Browser = "firefox" }));
Assert.Equal("MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true)", PlaywrightPreset.Describe(new PlaywrightOptions()));
Assert.Equal(PlaywrightPreset.Describe(new PlaywrightOptions()), new McpOptions().DescribePlaywright());
}
[Fact]
public void Prompt_section_mentions_send_only_when_asked()
{
var withSend = PlaywrightPreset.PromptSection(hasSend: true);
var without = PlaywrightPreset.PromptSection(hasSend: false);
Assert.StartsWith("## Browser", withSend, StringComparison.Ordinal);
Assert.Contains("call `send` with that", withSend, StringComparison.Ordinal);
Assert.DoesNotContain("`send`", without, StringComparison.Ordinal);
Assert.Contains("browser/", without, StringComparison.Ordinal);
Assert.Contains("browser_close", without, StringComparison.Ordinal);
Assert.Contains("passwords", without, StringComparison.Ordinal);
}
}