@@ -137,8 +137,8 @@ await navigator.Share(new ShareData { title = "Bit.Butil", url = "https://bitpla
var shareData = new ShareData()
{
Text = textValue,
- title = titleValue,
- url = urlValue
+ Title = titleValue,
+ Url = urlValue
};
await navigator.Share(shareData);
diff --git a/src/Butil/tests/Bit.Butil.Tests.Manual/InteropContract.cs b/src/Butil/tests/Bit.Butil.Tests.Manual/InteropContract.cs
index db069aaf9ad..0d4f96850e5 100644
--- a/src/Butil/tests/Bit.Butil.Tests.Manual/InteropContract.cs
+++ b/src/Butil/tests/Bit.Butil.Tests.Manual/InteropContract.cs
@@ -6,64 +6,6 @@
namespace ButilTests.Manual;
-///
-/// Captures the members Bit.Butil reaches by reflection at runtime, so a trimmed publish can be checked
-/// against an untrimmed capture of the same thing.
-///
-///
-/// The reflection-based registration only settles who gets registered. Two other things inside the
-/// library are resolved by name at runtime and would fail silently, in the browser, if the trimmer removed
-/// them - and neither shows up as a missing service:
-///
-/// - [JSInvokable] callbacks. JS dispatches these by method name through a
-/// DotNetObjectReference, including ones on internal types the consumer never names -
-/// DomEventsInterop, the observer interops, IndexedDbHandle. Nothing in a consumer's code
-/// references them.
-/// - JSON payload types. The DTOs and option objects crossing the interop boundary are
-/// (de)serialized by System.Text.Json reflecting over their constructors and properties, so a
-/// trimmed-away property turns into a silently null field rather than an error.
-///
-/// Both are meant to be covered by annotations already in the library - DotNetObjectReference.Create
-/// preserves public methods, and the Invoke<T> overloads annotate T with
-/// LinkerFlags.JsonSerialized. This class is what verifies that claim on real output rather than
-/// taking it on trust.
-///
-internal sealed record TypeContract(string TypeName, bool IsCallbackTarget, bool IsPayload, int PublicConstructors, string[] JSInvokableIdentifiers, string[] PublicProperties)
-{
- public string Serialize()
- => string.Join('|',
- TypeName,
- IsCallbackTarget ? "J" : "-",
- IsPayload ? "P" : "-",
- PublicConstructors.ToString(),
- string.Join(',', JSInvokableIdentifiers),
- string.Join(',', PublicProperties));
-
- public static TypeContract? Deserialize(string line)
- {
- var parts = line.Split('|');
- if (parts.Length != 6) return null;
-
- return new TypeContract(
- parts[0],
- parts[1] == "J",
- parts[2] == "P",
- int.TryParse(parts[3], out var constructors) ? constructors : 0,
- SplitList(parts[4]),
- SplitList(parts[5]));
- }
-
- private static string[] SplitList(string value)
- => value.Length == 0 ? [] : value.Split(',');
-}
-
-///
-/// What an untrimmed run records for a trimmed run to check itself against: the interop contract, plus the
-/// roster of [ButilService] names so the trimmed run can tell a genuinely trimmed-away service from a
-/// name that no longer refers to anything.
-///
-internal sealed record InteropManifest(string[] ServiceNames, TypeContract[] Types);
-
internal static class InteropContract
{
///
diff --git a/src/Butil/tests/Bit.Butil.Tests.Manual/InteropManifest.cs b/src/Butil/tests/Bit.Butil.Tests.Manual/InteropManifest.cs
new file mode 100644
index 00000000000..c620a0fe0b8
--- /dev/null
+++ b/src/Butil/tests/Bit.Butil.Tests.Manual/InteropManifest.cs
@@ -0,0 +1,14 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using System.Text;
+using Bit.Butil;
+using Microsoft.JSInterop;
+
+namespace ButilTests.Manual;
+
+///
+/// What an untrimmed run records for a trimmed run to check itself against: the interop contract, plus the
+/// roster of [ButilService] names so the trimmed run can tell a genuinely trimmed-away service from a
+/// name that no longer refers to anything.
+///
+internal sealed record InteropManifest(string[] ServiceNames, TypeContract[] Types);
diff --git a/src/Butil/tests/Bit.Butil.Tests.Manual/TypeContract.cs b/src/Butil/tests/Bit.Butil.Tests.Manual/TypeContract.cs
new file mode 100644
index 00000000000..26bec7a2a05
--- /dev/null
+++ b/src/Butil/tests/Bit.Butil.Tests.Manual/TypeContract.cs
@@ -0,0 +1,58 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using System.Text;
+using Bit.Butil;
+using Microsoft.JSInterop;
+
+namespace ButilTests.Manual;
+
+///
+/// Captures the members Bit.Butil reaches by reflection at runtime, so a trimmed publish can be checked
+/// against an untrimmed capture of the same thing.
+///
+///
+/// The reflection-based registration only settles who gets registered. Two other things inside the
+/// library are resolved by name at runtime and would fail silently, in the browser, if the trimmer removed
+/// them - and neither shows up as a missing service:
+///
+/// - [JSInvokable] callbacks. JS dispatches these by method name through a
+/// DotNetObjectReference, including ones on internal types the consumer never names -
+/// DomEventsInterop, the observer interops, IndexedDbHandle. Nothing in a consumer's code
+/// references them.
+/// - JSON payload types. The DTOs and option objects crossing the interop boundary are
+/// (de)serialized by System.Text.Json reflecting over their constructors and properties, so a
+/// trimmed-away property turns into a silently null field rather than an error.
+///
+/// Both are meant to be covered by annotations already in the library - DotNetObjectReference.Create
+/// preserves public methods, and the Invoke<T> overloads annotate T with
+/// LinkerFlags.JsonSerialized. This class is what verifies that claim on real output rather than
+/// taking it on trust.
+///
+internal sealed record TypeContract(string TypeName, bool IsCallbackTarget, bool IsPayload, int PublicConstructors, string[] JSInvokableIdentifiers, string[] PublicProperties)
+{
+ public string Serialize()
+ => string.Join('|',
+ TypeName,
+ IsCallbackTarget ? "J" : "-",
+ IsPayload ? "P" : "-",
+ PublicConstructors.ToString(),
+ string.Join(',', JSInvokableIdentifiers),
+ string.Join(',', PublicProperties));
+
+ public static TypeContract? Deserialize(string line)
+ {
+ var parts = line.Split('|');
+ if (parts.Length != 6) return null;
+
+ return new TypeContract(
+ parts[0],
+ parts[1] == "J",
+ parts[2] == "P",
+ int.TryParse(parts[3], out var constructors) ? constructors : 0,
+ SplitList(parts[4]),
+ SplitList(parts[5]));
+ }
+
+ private static string[] SplitList(string value)
+ => value.Length == 0 ? [] : value.Split(',');
+}
diff --git a/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ApiDetailsResult.cs b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ApiDetailsResult.cs
new file mode 100644
index 00000000000..3a5871962ac
--- /dev/null
+++ b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ApiDetailsResult.cs
@@ -0,0 +1,8 @@
+namespace Bit.Butil.Tests.Mcp.Infrastructure;
+
+// The shapes the structured tools answer with, re-declared here rather than shared with the server.
+// That is deliberate: these records ARE the contract a client codes against, so a property renamed
+// or dropped on the server has to fail a test instead of quietly flowing through a shared type.
+// Only the fields the suite asserts on are declared; unknown ones are ignored by the deserializer.
+
+public sealed record ApiDetailsResult(ApiTypeDetails? Details, ApiType[]? Types, string? Message);
diff --git a/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ApiInspection.cs b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ApiInspection.cs
new file mode 100644
index 00000000000..0d134453d46
--- /dev/null
+++ b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ApiInspection.cs
@@ -0,0 +1,18 @@
+namespace Bit.Butil.Tests.Mcp.Infrastructure;
+
+// The shapes the structured tools answer with, re-declared here rather than shared with the server.
+// That is deliberate: these records ARE the contract a client codes against, so a property renamed
+// or dropped on the server has to fail a test instead of quietly flowing through a shared type.
+// Only the fields the suite asserts on are declared; unknown ones are ignored by the deserializer.
+
+public sealed record ApiInspection(
+ string Query,
+ bool IsKnown,
+ string? Message,
+ string? Api,
+ string[]? Services,
+ string[]? Inject,
+ string? BrowserSupport,
+ string[]? Requires,
+ string[]? Disposables,
+ string[]? NextCalls);
diff --git a/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ApiMember.cs b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ApiMember.cs
new file mode 100644
index 00000000000..88771b61dc1
--- /dev/null
+++ b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ApiMember.cs
@@ -0,0 +1,8 @@
+namespace Bit.Butil.Tests.Mcp.Infrastructure;
+
+// The shapes the structured tools answer with, re-declared here rather than shared with the server.
+// That is deliberate: these records ARE the contract a client codes against, so a property renamed
+// or dropped on the server has to fail a test instead of quietly flowing through a shared type.
+// Only the fields the suite asserts on are declared; unknown ones are ignored by the deserializer.
+
+public sealed record ApiMember(string Name, string Kind, string? Type, string? Signature, string? Default, string? Summary, string? Remarks);
diff --git a/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ApiType.cs b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ApiType.cs
new file mode 100644
index 00000000000..82605b364c6
--- /dev/null
+++ b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ApiType.cs
@@ -0,0 +1,8 @@
+namespace Bit.Butil.Tests.Mcp.Infrastructure;
+
+// The shapes the structured tools answer with, re-declared here rather than shared with the server.
+// That is deliberate: these records ARE the contract a client codes against, so a property renamed
+// or dropped on the server has to fail a test instead of quietly flowing through a shared type.
+// Only the fields the suite asserts on are declared; unknown ones are ignored by the deserializer.
+
+public sealed record ApiType(string Name, string Kind, bool IsInjectable, string? Summary);
diff --git a/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ApiTypeDetails.cs b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ApiTypeDetails.cs
new file mode 100644
index 00000000000..3e3b1a48104
--- /dev/null
+++ b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ApiTypeDetails.cs
@@ -0,0 +1,17 @@
+namespace Bit.Butil.Tests.Mcp.Infrastructure;
+
+// The shapes the structured tools answer with, re-declared here rather than shared with the server.
+// That is deliberate: these records ARE the contract a client codes against, so a property renamed
+// or dropped on the server has to fail a test instead of quietly flowing through a shared type.
+// Only the fields the suite asserts on are declared; unknown ones are ignored by the deserializer.
+
+public sealed record ApiTypeDetails(
+ string Name,
+ string FullName,
+ string Kind,
+ string? Inject,
+ string[]? Implements,
+ string? Summary,
+ string? Remarks,
+ string? DocsUrl,
+ ApiMember[] Members);
diff --git a/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/WireContracts.cs b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ButilMcp.cs
similarity index 50%
rename from src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/WireContracts.cs
rename to src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ButilMcp.cs
index f7442c5fa23..2817c7088be 100644
--- a/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/WireContracts.cs
+++ b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ButilMcp.cs
@@ -103,119 +103,3 @@ public static class ButilMcp
/// The suffix a truncated answer ends with.
public const string TruncationMarker = "[truncated - the full text is longer than";
}
-
-///
-/// One row of the index GetButilDocsPage answers with when it is called with no slug - which
-/// is also the whole of butil://support, the page listing and the browser-support matrix
-/// having been folded into one table.
-///
-/// Parsed out of Markdown rather than deserialized, because that is the form the answer takes: a
-/// listing is read and then one value from it is passed back, so it ships as a table an agent reads
-/// instead of as a DTO with a tool description to advertise it. The suite reads it the same way,
-/// which also holds the table's columns to a shape.
-///
-///
-public sealed partial record DocsIndexRow(string Group, string Slug, string Title, string Summary, string[] Services, string Engines, string[] Requires)
-{
- /// Every row of the index, with the group heading each one sat under.
- public static DocsIndexRow[] ParseAll(string markdown)
- {
- var rows = new List();
- var group = string.Empty;
-
- foreach (var line in markdown.Split('\n').Select(line => line.TrimEnd('\r')))
- {
- if (line.StartsWith("## ", StringComparison.Ordinal))
- {
- group = line[3..].Trim();
- continue;
- }
-
- var match = RowRegex().Match(line);
- if (match.Success is false) continue;
-
- // Split on the pipes rather than on the pattern: the row is six cells, and a cell that
- // went missing should read as a short row here rather than as a row that did not match.
- // Such a row is thrown on rather than dropped - a table that quietly lost a column would
- // otherwise shrink every listing the suite compares against it, on both sides at once.
- var cells = SplitCells(line);
- if (cells.Length != 6) throw new FormatException($"The index has a row of {cells.Length} cells rather than six: {line.Trim()}");
-
- rows.Add(new DocsIndexRow(group, match.Groups["slug"].Value, cells[1], cells[2], Cell(cells[3]), cells[4], Cell(cells[5])));
- }
-
- return [.. rows];
- }
-
- ///
- /// A row's cells, split on the pipes that are column breaks rather than on every pipe. The
- /// renderer writes a pipe inside a cell as \|, which is one character of that cell's
- /// text; splitting on the raw character would read such a row as a column too long and throw on
- /// it - reporting the corruption this exists to catch against the one row that is not corrupt.
- ///
- private static string[] SplitCells(string line)
- {
- var body = line.Trim();
- if (body.StartsWith('|')) body = body[1..];
- if (body.EndsWith('|') && body.EndsWith(@"\|", StringComparison.Ordinal) is false) body = body[..^1];
-
- var cells = new List();
- var cell = new StringBuilder();
-
- for (var i = 0; i < body.Length; i++)
- {
- if (body[i] == '\\' && i + 1 < body.Length && body[i + 1] == '|')
- {
- cell.Append('|');
- i++;
- }
- else if (body[i] == '|')
- {
- cells.Add(cell.ToString().Trim());
- cell.Clear();
- }
- else cell.Append(body[i]);
- }
-
- cells.Add(cell.ToString().Trim());
-
- return [.. cells];
- }
-
- /// A list cell: comma-separated, or "-" when the row has none of that thing.
- private static string[] Cell(string text)
- => text is "-" or "" ? [] : [.. text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
-
- [GeneratedRegex(@"^\|\s*`(?[^`]+)`\s*\|")]
- private static partial Regex RowRegex();
-}
-
-///
-/// A follow-up call a search hit names, e.g. GetButilDocsPage(slug: "clipboard").
-///
-/// Every hit the search returns carries one, and the whole design rests on it being callable
-/// verbatim: an agent is told to make that call next, and a hit that names a call which does not
-/// resolve sends it somewhere there is nothing. Parsing them back into real calls is how the suite
-/// proves the promise instead of assuming it.
-///
-///
-public sealed partial record ToolCallReference(string Tool, string Argument, string Value)
-{
- public static ToolCallReference? Parse(string? text)
- {
- if (string.IsNullOrWhiteSpace(text)) return null;
-
- var match = CallRegex().Match(text.Trim());
-
- return match.Success
- ? new ToolCallReference(match.Groups["tool"].Value, match.Groups["argument"].Value, match.Groups["value"].Value)
- : null;
- }
-
- public Dictionary Arguments => new(StringComparer.Ordinal) { [Argument] = Value };
-
- // Greedy on the value so a heading containing a quote still parses: the call always ends with
- // the same two characters.
- [GeneratedRegex("""^(?\w+)\((?\w+):\s*"(?.*)"\)$""")]
- private static partial Regex CallRegex();
-}
diff --git a/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/DocsIndexRow.cs b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/DocsIndexRow.cs
new file mode 100644
index 00000000000..34f8376f4c1
--- /dev/null
+++ b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/DocsIndexRow.cs
@@ -0,0 +1,90 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Bit.Butil.Tests.Mcp.Infrastructure;
+
+///
+/// One row of the index GetButilDocsPage answers with when it is called with no slug - which
+/// is also the whole of butil://support, the page listing and the browser-support matrix
+/// having been folded into one table.
+///
+/// Parsed out of Markdown rather than deserialized, because that is the form the answer takes: a
+/// listing is read and then one value from it is passed back, so it ships as a table an agent reads
+/// instead of as a DTO with a tool description to advertise it. The suite reads it the same way,
+/// which also holds the table's columns to a shape.
+///
+///
+public sealed partial record DocsIndexRow(string Group, string Slug, string Title, string Summary, string[] Services, string Engines, string[] Requires)
+{
+ /// Every row of the index, with the group heading each one sat under.
+ public static DocsIndexRow[] ParseAll(string markdown)
+ {
+ var rows = new List();
+ var group = string.Empty;
+
+ foreach (var line in markdown.Split('\n').Select(line => line.TrimEnd('\r')))
+ {
+ if (line.StartsWith("## ", StringComparison.Ordinal))
+ {
+ group = line[3..].Trim();
+ continue;
+ }
+
+ var match = RowRegex().Match(line);
+ if (match.Success is false) continue;
+
+ // Split on the pipes rather than on the pattern: the row is six cells, and a cell that
+ // went missing should read as a short row here rather than as a row that did not match.
+ // Such a row is thrown on rather than dropped - a table that quietly lost a column would
+ // otherwise shrink every listing the suite compares against it, on both sides at once.
+ var cells = SplitCells(line);
+ if (cells.Length != 6) throw new FormatException($"The index has a row of {cells.Length} cells rather than six: {line.Trim()}");
+
+ rows.Add(new DocsIndexRow(group, match.Groups["slug"].Value, cells[1], cells[2], Cell(cells[3]), cells[4], Cell(cells[5])));
+ }
+
+ return [.. rows];
+ }
+
+ ///
+ /// A row's cells, split on the pipes that are column breaks rather than on every pipe. The
+ /// renderer writes a pipe inside a cell as \|, which is one character of that cell's
+ /// text; splitting on the raw character would read such a row as a column too long and throw on
+ /// it - reporting the corruption this exists to catch against the one row that is not corrupt.
+ ///
+ private static string[] SplitCells(string line)
+ {
+ var body = line.Trim();
+ if (body.StartsWith('|')) body = body[1..];
+ if (body.EndsWith('|') && body.EndsWith(@"\|", StringComparison.Ordinal) is false) body = body[..^1];
+
+ var cells = new List();
+ var cell = new StringBuilder();
+
+ for (var i = 0; i < body.Length; i++)
+ {
+ if (body[i] == '\\' && i + 1 < body.Length && body[i + 1] == '|')
+ {
+ cell.Append('|');
+ i++;
+ }
+ else if (body[i] == '|')
+ {
+ cells.Add(cell.ToString().Trim());
+ cell.Clear();
+ }
+ else cell.Append(body[i]);
+ }
+
+ cells.Add(cell.ToString().Trim());
+
+ return [.. cells];
+ }
+
+ /// A list cell: comma-separated, or "-" when the row has none of that thing.
+ private static string[] Cell(string text)
+ => text is "-" or "" ? [] : [.. text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
+
+ [GeneratedRegex(@"^\|\s*`(?[^`]+)`\s*\|")]
+ private static partial Regex RowRegex();
+}
diff --git a/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/FeaturePlan.cs b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/FeaturePlan.cs
new file mode 100644
index 00000000000..c64b4efb15b
--- /dev/null
+++ b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/FeaturePlan.cs
@@ -0,0 +1,20 @@
+namespace Bit.Butil.Tests.Mcp.Infrastructure;
+
+// The shape PlanButilFeature answers with, re-declared here rather than shared with the server.
+// That is deliberate: this record IS the contract a client codes against, so a property renamed
+// or dropped on the server has to fail a test instead of quietly flowing through a shared type.
+// Only the fields the suite asserts on are declared; unknown ones are ignored by the deserializer.
+
+public sealed record FeaturePlan(
+ ApiInspection[] Apis,
+ string[] Unknown,
+ bool RequiresSecureContext,
+ bool RequiresPermission,
+ bool RequiresUserGesture,
+ string[] EngineLimited,
+ string[] Checklist,
+ string[]? Ignored);
+
+// No record for the listings: they are answered as Markdown, not as structured content, which is
+// what let the four tools that used to serve them go away. DocsIndexRow parses the docs index, and
+// McpTestBase.ListAsync reads the identifiers out of the other two.
diff --git a/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/SearchHit.cs b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/SearchHit.cs
new file mode 100644
index 00000000000..e306f06e293
--- /dev/null
+++ b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/SearchHit.cs
@@ -0,0 +1,8 @@
+namespace Bit.Butil.Tests.Mcp.Infrastructure;
+
+// The shapes the structured tools answer with, re-declared here rather than shared with the server.
+// That is deliberate: these records ARE the contract a client codes against, so a property renamed
+// or dropped on the server has to fail a test instead of quietly flowing through a shared type.
+// Only the fields the suite asserts on are declared; unknown ones are ignored by the deserializer.
+
+public sealed record SearchHit(string Kind, string Title, string? Context, string Tool, string Snippet);
diff --git a/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/SearchResult.cs b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/SearchResult.cs
new file mode 100644
index 00000000000..1ccc74144e8
--- /dev/null
+++ b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/SearchResult.cs
@@ -0,0 +1,8 @@
+namespace Bit.Butil.Tests.Mcp.Infrastructure;
+
+// The shapes the structured tools answer with, re-declared here rather than shared with the server.
+// That is deliberate: these records ARE the contract a client codes against, so a property renamed
+// or dropped on the server has to fail a test instead of quietly flowing through a shared type.
+// Only the fields the suite asserts on are declared; unknown ones are ignored by the deserializer.
+
+public sealed record SearchResult(SearchHit[] Hits, string? Message);
diff --git a/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ToolCallReference.cs b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ToolCallReference.cs
new file mode 100644
index 00000000000..cf2b2a66630
--- /dev/null
+++ b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/ToolCallReference.cs
@@ -0,0 +1,34 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Bit.Butil.Tests.Mcp.Infrastructure;
+
+///
+/// A follow-up call a search hit names, e.g. GetButilDocsPage(slug: "clipboard").
+///
+/// Every hit the search returns carries one, and the whole design rests on it being callable
+/// verbatim: an agent is told to make that call next, and a hit that names a call which does not
+/// resolve sends it somewhere there is nothing. Parsing them back into real calls is how the suite
+/// proves the promise instead of assuming it.
+///
+///
+public sealed partial record ToolCallReference(string Tool, string Argument, string Value)
+{
+ public static ToolCallReference? Parse(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return null;
+
+ var match = CallRegex().Match(text.Trim());
+
+ return match.Success
+ ? new ToolCallReference(match.Groups["tool"].Value, match.Groups["argument"].Value, match.Groups["value"].Value)
+ : null;
+ }
+
+ public Dictionary Arguments => new(StringComparer.Ordinal) { [Argument] = Value };
+
+ // Greedy on the value so a heading containing a quote still parses: the call always ends with
+ // the same two characters.
+ [GeneratedRegex("""^(?\w+)\((?\w+):\s*"(?.*)"\)$""")]
+ private static partial Regex CallRegex();
+}
diff --git a/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/WireDtos.cs b/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/WireDtos.cs
deleted file mode 100644
index fe5a9beef76..00000000000
--- a/src/Butil/tests/Bit.Butil.Tests.Mcp/Infrastructure/WireDtos.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-namespace Bit.Butil.Tests.Mcp.Infrastructure;
-
-// The shapes the structured tools answer with, re-declared here rather than shared with the server.
-// That is deliberate: these records ARE the contract a client codes against, so a property renamed
-// or dropped on the server has to fail a test instead of quietly flowing through a shared type.
-// Only the fields the suite asserts on are declared; unknown ones are ignored by the deserializer.
-
-public sealed record SearchResult(SearchHit[] Hits, string? Message);
-
-public sealed record SearchHit(string Kind, string Title, string? Context, string Tool, string Snippet);
-
-public sealed record ApiType(string Name, string Kind, bool IsInjectable, string? Summary);
-
-public sealed record ApiMember(string Name, string Kind, string? Type, string? Signature, string? Default, string? Summary, string? Remarks);
-
-public sealed record ApiTypeDetails(
- string Name,
- string FullName,
- string Kind,
- string? Inject,
- string[]? Implements,
- string? Summary,
- string? Remarks,
- string? DocsUrl,
- ApiMember[] Members);
-
-public sealed record ApiDetailsResult(ApiTypeDetails? Details, ApiType[]? Types, string? Message);
-
-public sealed record ApiInspection(
- string Query,
- bool IsKnown,
- string? Message,
- string? Api,
- string[]? Services,
- string[]? Inject,
- string? BrowserSupport,
- string[]? Requires,
- string[]? Disposables,
- string[]? NextCalls);
-
-public sealed record FeaturePlan(
- ApiInspection[] Apis,
- string[] Unknown,
- bool RequiresSecureContext,
- bool RequiresPermission,
- bool RequiresUserGesture,
- string[] EngineLimited,
- string[] Checklist,
- string[]? Ignored);
-
-// No record for the listings: they are answered as Markdown, not as structured content, which is
-// what let the four tools that used to serve them go away. DocsIndexRow in WireContracts.cs parses
-// the docs index, and McpTestBase.ListAsync reads the identifiers out of the other two.
diff --git a/src/Butil/tests/Bit.Butil.Tests.Mcp/README.md b/src/Butil/tests/Bit.Butil.Tests.Mcp/README.md
index 25128d33d9f..411bd2e29f6 100644
--- a/src/Butil/tests/Bit.Butil.Tests.Mcp/README.md
+++ b/src/Butil/tests/Bit.Butil.Tests.Mcp/README.md
@@ -44,8 +44,10 @@ suite runs the app the way the app runs.
| --- | --- |
| `Infrastructure/McpServerFixture.cs` | Assembly-level `[SetUpFixture]`: boots the demo server on a free port, into its own artifacts path so a developer's running instance cannot lock the build. |
| `Infrastructure/McpTestBase.cs` | A live `McpClient` per fixture, and the helpers the assertions are written in. |
-| `Infrastructure/WireContracts.cs` | The server's public inventory, written down tool, resource and prompt names are identifiers clients store, so renaming one has to fail a test. Also parses the `Tool` strings hits hand back into real calls. |
-| `Infrastructure/WireDtos.cs` | The payloads the data tools answer with, re-declared rather than shared with the server: these records **are** the contract a client codes against. |
+| `Infrastructure/ButilMcp.cs` | The server's public inventory, written down: tool, resource and prompt names are identifiers clients store, so renaming one has to fail a test. |
+| `Infrastructure/ToolCallReference.cs` | The follow-up call a search hit names, parsed back into a real call so the suite invokes what the hit promised instead of assuming it resolves. |
+| `Infrastructure/DocsIndexRow.cs` | One row of the index `GetButilDocsPage` answers with when it is called with no slug, parsed out of Markdown because that is the form the answer takes. |
+| `Infrastructure/ApiDetailsResult.cs`, `ApiInspection.cs`, `ApiMember.cs`, `ApiType.cs`, `ApiTypeDetails.cs`, `FeaturePlan.cs`, `SearchHit.cs`, `SearchResult.cs` | The payloads the data tools answer with, re-declared rather than shared with the server: these records **are** the contract a client codes against. |
| `ServerContractTests.cs` | The handshake serverInfo, advertised capabilities, and the instructions the model carries all session. |
| `ToolSurfaceTests.cs` | tools/list: the names, titles, descriptions, annotations, input schemas, the absence of output schemas which would double every answer and the standing context cost of the whole surface. |
| `ToolBehaviourTests.cs` | What each tool answers when called properly including rendering **every** documentation page. |
@@ -61,7 +63,7 @@ suite runs the app the way the app runs.
## What a failure here usually means
* **A tool, resource or prompt name changed** that is a breaking change for every client that
- already holds the old name, and `WireContracts.cs` is where you accept it deliberately. The count
+ already holds the old name, and `ButilMcp.cs` is where you accept it deliberately. The count
is part of that contract: the surface is deliberately **seven** tools, because a description is
paid for in every request of every session. A listing is not a tool here it is what a retrieval
tool answers when called with no argument and `PlanButilFeature` answers for one API as well as