forked from admin/VolcanicAgenticCore
146 lines
6.1 KiB
C#
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__<server>__<tool></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__<server>__</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();
|
|
}
|