Files
EugeneTes b30c71d30a 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.
2026-09-26 20:17:37 +02:00

65 lines
3.1 KiB
C#

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();
}