Files
Playa/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ToolCatalog.cs

146 lines
6.1 KiB
C#

using System.Buffers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;
using Volcanic.AgenticCore.Abstractions.Providers;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>
/// The active tools as the proposal MCP server advertises them. Claude Code names an MCP tool
/// <c>mcp__&lt;server&gt;__&lt;tool&gt;</c> after replacing every character outside <c>[A-Za-z0-9_-]</c>
/// with <c>_</c>, so any other name gets a digest alias; otherwise its initialisation would report a
/// name this catalogue never offered.
/// </summary>
public sealed partial class ToolCatalog
{
public const int MaxTools = 256;
public const int MaxCatalogBytes = 2 * 1024 * 1024;
public const string DefaultServerName = "volcanic";
private const int MaxAliasLength = 48;
private static readonly JsonWriterOptions WriterOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
public ToolCatalog(IReadOnlyList<ToolDescriptor> tools, string serverName = DefaultServerName)
{
ArgumentNullException.ThrowIfNull(tools);
if (string.IsNullOrWhiteSpace(serverName) || !SafeName().IsMatch(serverName))
throw new ArgumentException("MCP server names must match [A-Za-z0-9_-]{1,48}.", nameof(serverName));
if (tools.Count > MaxTools)
throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolInit, $"At most {MaxTools} active tools are supported");
foreach (var tool in tools)
{
if (tool is null || string.IsNullOrWhiteSpace(tool.Name) || tool.Description is null)
throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, "Context contained an invalid active tool");
if (tool.Parameters.ValueKind != JsonValueKind.Object)
throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, $"Active tool schema for '{tool.Name}' must be an object");
}
ServerName = serverName;
var sorted = tools.OrderBy(t => t.Name, StringComparer.Ordinal).ToList();
for (var i = 1; i < sorted.Count; i++)
{
if (sorted[i - 1].Name == sorted[i].Name)
throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, $"Duplicate active tool: {sorted[i].Name}");
}
var used = new HashSet<string>(StringComparer.Ordinal);
var transportToTool = new Dictionary<string, string>(StringComparer.Ordinal);
var toolToTransport = new Dictionary<string, string>(StringComparer.Ordinal);
var aliases = new List<string>(sorted.Count);
var transportNames = new List<string>(sorted.Count);
foreach (var tool in sorted)
{
var alias = UniqueAlias(tool.Name, used);
var transport = $"{TransportPrefix}{alias}";
aliases.Add(alias);
transportNames.Add(transport);
transportToTool[transport] = tool.Name;
toolToTransport[tool.Name] = transport;
}
Tools = sorted;
Aliases = aliases;
TransportNames = transportNames;
TransportToTool = transportToTool;
ToolToTransport = toolToTransport;
Json = BuildJson(sorted, aliases);
}
public string ServerName { get; }
/// <summary><c>mcp__&lt;server&gt;__</c>.</summary>
public string TransportPrefix => $"mcp__{ServerName}__";
/// <summary>Active tools sorted by name.</summary>
public IReadOnlyList<ToolDescriptor> Tools { get; }
/// <summary>Alias of each tool in <see cref="Tools"/> order.</summary>
public IReadOnlyList<string> Aliases { get; }
/// <summary>Transport name of each tool in <see cref="Tools"/> order; exactly what Claude Code's init record must list.</summary>
public IReadOnlyList<string> TransportNames { get; }
public IReadOnlyDictionary<string, string> TransportToTool { get; }
public IReadOnlyDictionary<string, string> ToolToTransport { get; }
public int Count => Tools.Count;
public bool IsEmpty => Tools.Count == 0;
/// <summary>The catalogue file content: a JSON array of <c>{name, description, inputSchema}</c>.</summary>
public string Json { get; }
public string ToJson() => Json;
/// <summary>The alias for a tool name before collision suffixing.</summary>
public static string AliasFor(string name) =>
SafeName().IsMatch(name) ? name : $"tool_{Digest16(name)}";
/// <summary>First 16 hex characters of SHA-256 of the UTF-8 name.</summary>
public static string Digest16(string value) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..16];
private static string UniqueAlias(string name, HashSet<string> used)
{
var candidate = AliasFor(name);
var stem = candidate;
var suffix = 2;
while (used.Contains(candidate))
{
var tail = $"_{suffix++}";
candidate = stem[..Math.Min(stem.Length, MaxAliasLength - tail.Length)] + tail;
}
used.Add(candidate);
return candidate;
}
private static string BuildJson(IReadOnlyList<ToolDescriptor> tools, IReadOnlyList<string> aliases)
{
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, WriterOptions))
{
writer.WriteStartArray();
for (var i = 0; i < tools.Count; i++)
{
writer.WriteStartObject();
writer.WriteString("name", aliases[i]);
writer.WriteString("description", tools[i].Description);
writer.WritePropertyName("inputSchema");
tools[i].Parameters.WriteTo(writer);
writer.WriteEndObject();
}
writer.WriteEndArray();
}
if (buffer.WrittenCount + 1 > MaxCatalogBytes)
throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolInit, $"Tool catalog exceeds {MaxCatalogBytes} bytes");
return Encoding.UTF8.GetString(buffer.WrittenSpan);
}
[GeneratedRegex("^[A-Za-z0-9_-]{1,48}$", RegexOptions.CultureInvariant)]
private static partial Regex SafeName();
}