Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,43 @@ public async Task Core_ToolsCall_OutsideThePinnedSet_GetsTheSdkUnknownToolError(
Assert.Contains("Unknown tool", error.GetProperty("message").GetString(), StringComparison.OrdinalIgnoreCase);
}

/// <summary>A <c>tools/call</c> that sends <paramref name="argumentsJson"/> as the call's arguments object.</summary>
private static Task<(int StatusCode, string Body)> ToolsCallWithArgumentsAsync(
TestServer server, string path, string host, IPAddress remote, string toolName, string argumentsJson)
=> SendJsonRpcAsync(
server, path, host, remote, "tools/call", $"{{\"name\":\"{toolName}\",\"arguments\":{argumentsJson}}}", bearer: null);

/// <summary>
/// The argument guard's typed refusal reaches a caller over the stateless HTTP pipeline the product ships. The guard
/// takes its record of each tool's parameter types from the request's services, and the in-process server the other
/// pins run on hands it those, so only a call made like this one shows that a real client's request resolves it too.
/// A null for <c>hours_back</c> is the proof: the schema says "integer" and does not say which parameters take a
/// null, so only the typed path refuses it. With the record missing the null would reach the SDK's binder, which
/// answers "An error occurred invoking ..." and names neither the argument nor the reason.
/// </summary>
[Fact]
public async Task ToolsCall_ANullForAWholeNumber_IsRefusedByName_OverTheShippedHttpPipeline()
{
using var server = await BuildServer(networkMode: false);

var (statusCode, body) = await ToolsCallWithArgumentsAsync(
server, "/", "localhost", IPAddress.Loopback, "get_alert_history", "{\"hours_back\":null}");
Assert.Equal(StatusCodes.Status200OK, statusCode);

var result = ReadJsonRpcResult(body);
Assert.True(result.GetProperty("isError").GetBoolean(), $"expected an error result, got: {body}");
var text = result.GetProperty("content").EnumerateArray().Single().GetProperty("text").GetString() ?? "";
Assert.True(
PerformanceMonitor.Common.McpHelpers.IsRefusalEnvelope(text), $"expected the argument guard's refusal, got: {text}");

using var refusal = JsonDocument.Parse(text);
Assert.Equal("hours_back", refusal.RootElement.GetProperty("hints").GetProperty("parameter").GetString());
Assert.Contains(
"Argument 'hours_back' for tool 'get_alert_history' takes a whole number of hours, and cannot be null, and the call sent null.",
refusal.RootElement.GetProperty("message").GetString(),
StringComparison.Ordinal);
}

private static JsonElement ReadJsonRpcResult(string body)
{
using var document = JsonDocument.Parse(ExtractJsonPayload(body));
Expand Down
420 changes: 420 additions & 0 deletions Darling/Darling.Tests/McpArgumentTypeRows.cs

Large diffs are not rendered by default.

102 changes: 102 additions & 0 deletions Darling/Darling.Tests/McpArgumentTypeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// The call-tool guard checks each argument against the CLR type of its parameter, not only against the schema's word
/// "integer". The schema cannot tell an <c>int</c> from a <c>long</c> or a <c>byte</c>, and says nothing of
/// nullability, so a value the binder cannot read (an int past int range, null for a parameter that is not
/// nullable, a number for a string, a string for a boolean, an integer array with a fraction in it) used to reach the
/// SDK and come back as "An error occurred invoking ...", with no word on which argument was wrong. Each row of
/// <see cref="McpArgumentTypeRows"/> pins both halves: the binder on its own reads or rejects the value as the row
/// says, and the guard refuses exactly the values the binder rejects, by a message that names the argument.
/// </summary>
public sealed class McpArgumentTypeTests : IClassFixture<McpArgumentTypeTests.Hosts>
{
/// <summary>The two in-process servers every row shares: one with the guard in, one with it left out.</summary>
public sealed class Hosts : IAsyncLifetime
{
internal McpInProcessHost Guarded { get; private set; } = null!;

internal McpInProcessHost Unguarded { get; private set; } = null!;

public async ValueTask InitializeAsync()
{
Guarded = await McpWholeNumberArgumentTests.StartHostAsync(true, typeof(McpArgumentTypeProbeTools));
Unguarded = await McpWholeNumberArgumentTests.StartHostAsync(false, typeof(McpArgumentTypeProbeTools));
}

public async ValueTask DisposeAsync()
{
await Guarded.DisposeAsync();
await Unguarded.DisposeAsync();
}
}

private readonly Hosts _hosts;

public McpArgumentTypeTests(Hosts hosts) => _hosts = hosts;

public static TheoryData<string, string, bool, string> Rows() => McpArgumentTypeRows.All();

[Theory]
[MemberData(nameof(Rows))]
public async Task EachValue_IsRefusedByName_ExactlyWhenTheBinderCannotReadIt(
string type, string raw, bool refused, string says)
{
var toolTypes = McpWholeNumberArgumentTests.RegisteredToolTypes()
.Append(typeof(McpArgumentTypeProbeTools))
.ToList();

await McpArgumentTypeRows.CheckAsync(
_hosts.Guarded, _hosts.Unguarded, toolTypes, type, raw, refused, says, TestContext.Current.CancellationToken);
}

/// <summary>The host records a type for every parameter each tool advertises, and the types agree with the schema,
/// so the guard's typed check covers every shipped tool instead of quietly falling back to the schema alone.</summary>
[Fact]
public void EveryAdvertisedParameter_HasARecordedType_ThatAgreesWithTheSchema()
{
var problems = McpArgumentTypeRows.RecordedTypeProblems(_hosts.Guarded);

Assert.True(problems.Count == 0, $"{problems.Count} problems:\n" + string.Join("\n", problems.Take(40)));
}

/// <summary>The reverse of the check above: the record holds only what a caller may send, so every parameter it holds
/// is one the tool's schema advertises. A parameter the schema leaves out (a cancellation token the SDK supplies, a
/// service resolved from DI) is not recorded, whatever the tool method declares.</summary>
[Fact]
public void EveryRecordedParameter_IsOneTheSchemaAdvertises()
{
var problems = McpArgumentTypeRows.RecordedButNotAdvertisedProblems(_hosts.Guarded);

Assert.True(problems.Count == 0, $"{problems.Count} problems:\n" + string.Join("\n", problems.Take(40)));
}

/// <summary>The table is only worth having if it reaches every CLR type the shipped tools declare, so a tool added
/// with a type the table lacks fails here by name instead of passing through unchecked.</summary>
[Fact]
public void EveryClrTypeAShippedToolDeclares_IsOneTheTableCovers()
{
var covered = McpArgumentTypeRows.CoveredTypeKeys();
var declared = McpArgumentTypeRows.ShippedParameterTypeKeys(
_hosts.Guarded, McpWholeNumberArgumentTests.RegisteredToolTypes());

var missing = declared.Where(key => !covered.Contains(key)).ToList();
Assert.True(
missing.Count == 0,
"Shipped tools declare parameters of these types, which the table has no row for: " + string.Join(", ", missing));
}
}
9 changes: 9 additions & 0 deletions Darling/Darling.Tests/McpInProcessHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ private McpInProcessHost(
/// <summary>The tools the server registered, with the schemas it advertises.</summary>
public IReadOnlyList<McpServerTool> RegisteredTools => _provider.GetServices<McpServerTool>().ToList();

/// <summary>The declared parameter types the host recorded as it created the tools: what the guard reads through
/// the request's services, so a test can hand them to <see cref="McpUnknownArgumentGuard.Refuse"/>.</summary>
public McpToolParameterTypes ParameterTypes => _provider.GetRequiredService<McpToolParameterTypes>();

/// <param name="toolTypes">The tool classes to register, as the host registers them.</param>
/// <param name="isServiceParameter">Which parameter types the host resolves from DI rather than from the call.</param>
/// <param name="inertInstanceFor">An inert instance for a service type that cannot be left uninitialized (an
Expand Down Expand Up @@ -292,6 +296,11 @@ internal sealed class ToolExceptionLog : ILoggerProvider
/// </summary>
public bool HasBindingFailure => Entries.Any(e => Chain(e.Exception).Any(x => x is JsonException));

/// <summary>Whether any exception logged after the first <paramref name="alreadySeen"/> entries is a binding
/// failure, so one call among many on a shared host can be judged on its own.</summary>
public bool BindingFailureSince(int alreadySeen) =>
Entries.Skip(alreadySeen).Any(e => Chain(e.Exception).Any(x => x is JsonException));

/// <summary>Each logged exception as its category and the types in its chain, for a failure message.</summary>
public string Describe() =>
Entries.Count == 0
Expand Down
Loading
Loading