forked from admin/VolcanicAgenticCore
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:
64
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpLaunch.cs
Normal file
64
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpLaunch.cs
Normal 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();
|
||||
}
|
||||
21
extensions/Volcanic.AgenticCore.Extensions.Mcp/StderrTail.cs
Normal file
21
extensions/Volcanic.AgenticCore.Extensions.Mcp/StderrTail.cs
Normal 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]; } }
|
||||
}
|
||||
40
extensions/Volcanic.AgenticCore.Extensions.Mcp/ToolNaming.cs
Normal file
40
extensions/Volcanic.AgenticCore.Extensions.Mcp/ToolNaming.cs
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user