feat(mcp): server launch plumbing and tool naming

Spec: the complete environment goes to the SDK with InheritEnvironmentVariables=false instead of nulls; the cache key also covers cwd and prefix.
This commit is contained in:
EugeneTes
2026-09-26 20:17:37 +02:00
parent 5b7d593427
commit b30c71d30a
8 changed files with 319 additions and 5 deletions

View File

@@ -0,0 +1,64 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using ModelContextProtocol.Client;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>Turns a server config into a spawn request and the SDK's transport options.</summary>
internal static partial class McpLaunch
{
public const string WorkspaceToken = "${workspace}";
public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(5);
public static string ExpandWorkspace(string value, string workspace) =>
value.Replace(WorkspaceToken, workspace, StringComparison.Ordinal);
/// <summary><c>${workspace}</c>, then <c>${env:NAME}</c> from the host (unset → empty). Only env values get the second.</summary>
public static string ExpandEnvValue(string value, string workspace, Func<string, string?> getEnv) =>
EnvReference().Replace(ExpandWorkspace(value, workspace), m => getEnv(m.Groups[1].Value) ?? "");
public static ProcessSpawnRequest BuildRequest(string name, McpServerConfig config, string workspace, Func<string, string?> getEnv) => new()
{
Purpose = ProcessPurpose.McpServer,
Command = ExpandWorkspace(config.Command, workspace),
Arguments = [.. config.Args.Select(a => ExpandWorkspace(a, workspace))],
Cwd = config.Cwd is { Length: > 0 } cwd ? ExpandWorkspace(cwd, workspace) : workspace,
Environment = config.Env.Count == 0
? null
: config.Env.ToDictionary(kv => kv.Key, kv => kv.Value is null ? null : ExpandEnvValue(kv.Value, workspace, getEnv), StringComparer.Ordinal),
Origin = name,
};
/// <summary>The spawn's environment is complete (process policy), so the SDK must not merge the host's back in.</summary>
public static StdioClientTransportOptions TransportOptions(string name, ProcessSpawnContext spawn, Action<string> onStderrLine) => new()
{
Name = name,
Command = spawn.Command,
Arguments = [.. spawn.Arguments],
WorkingDirectory = spawn.Cwd,
InheritEnvironmentVariables = false,
EnvironmentVariables = new Dictionary<string, string?>(spawn.Environment.Select(kv => KeyValuePair.Create(kv.Key, (string?)kv.Value))),
ShutdownTimeout = ShutdownTimeout,
StandardErrorLines = onStderrLine,
};
/// <summary>Server name plus a digest of the unexpanded command, args, env, cwd and prefix: every chat shares it, and no host secret enters it.</summary>
public static string CacheKey(string name, McpServerConfig config)
{
var canonical = JsonSerializer.Serialize(new
{
config.Command,
config.Args,
Env = config.Env.OrderBy(kv => kv.Key, StringComparer.Ordinal).Select(kv => new[] { kv.Key, kv.Value }).ToArray(),
config.Cwd,
config.ToolPrefix,
});
return $"{name}:{Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(canonical)))}";
}
[GeneratedRegex(@"\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}")]
private static partial Regex EnvReference();
}

View File

@@ -0,0 +1,21 @@
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>The last lines a server wrote to stderr: a server that dies explains why only there.</summary>
internal sealed class StderrTail(int capacity = 20)
{
private readonly Queue<string> _lines = new();
private readonly Lock _lock = new();
public void Add(string line)
{
lock (_lock)
{
_lines.Enqueue(line);
while (_lines.Count > capacity) _lines.Dequeue();
}
}
public void Clear() { lock (_lock) _lines.Clear(); }
public IReadOnlyList<string> Lines { get { lock (_lock) return [.. _lines]; } }
}

View File

@@ -0,0 +1,40 @@
using System.Text.RegularExpressions;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>
/// Exposed tool names: <c>prefix + mcpName</c>, characters outside <c>[A-Za-z0-9_-]</c> replaced by <c>_</c>, at most 48
/// characters so the Claude Code provider's <c>ToolCatalog</c> keeps them readable, duplicates suffixed <c>_2</c>, <c>_3</c>.
/// </summary>
internal static partial class ToolNaming
{
public const int MaxLength = 48;
public static string Sanitize(string? prefix, string mcpName)
{
var name = InvalidCharacters().Replace((prefix ?? "") + mcpName, "_");
if (name.Length == 0) name = "tool";
return name.Length > MaxLength ? name[..MaxLength] : name;
}
public static IReadOnlyList<string> Expose(IReadOnlyList<string> mcpNames, string? prefix)
{
var used = new HashSet<string>(StringComparer.Ordinal);
var result = new List<string>(mcpNames.Count);
foreach (var mcpName in mcpNames)
{
var baseName = Sanitize(prefix, mcpName);
var name = baseName;
for (var n = 2; !used.Add(name); n++)
{
var suffix = $"_{n}";
name = (baseName.Length + suffix.Length > MaxLength ? baseName[..(MaxLength - suffix.Length)] : baseName) + suffix;
}
result.Add(name);
}
return result;
}
[GeneratedRegex("[^A-Za-z0-9_-]")]
private static partial Regex InvalidCharacters();
}