Skip to content

Commit 30c9056

Browse files
authored
Surface tool creation failures as unavailable MCP tools (#10328)
1 parent 9320b2a commit 30c9056

5 files changed

Lines changed: 79 additions & 1 deletion

File tree

src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Diagnostics/AggregateMcpDiagnosticEventListener.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ public IDisposable UpdateTools()
5151
return new AggregateActivityScope(scopes);
5252
}
5353

54+
public void ToolCreationFailed(string toolName, Exception exception)
55+
{
56+
foreach (var listener in listeners)
57+
{
58+
listener.ToolCreationFailed(toolName, exception);
59+
}
60+
}
61+
5462
public void ValidationErrors(IReadOnlyList<IError> errors)
5563
{
5664
foreach (var listener in listeners)

src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Diagnostics/IMcpDiagnosticEvents.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ public interface IMcpDiagnosticEvents
3737
/// </returns>
3838
IDisposable UpdateTools();
3939

40+
/// <summary>
41+
/// Called when creating a tool from a validated tool document fails.
42+
/// </summary>
43+
/// <param name="toolName">The name of the tool.</param>
44+
/// <param name="exception">The exception that occurred.</param>
45+
void ToolCreationFailed(string toolName, Exception exception);
46+
4047
/// <summary>
4148
/// Called when errors occur while validating a tool document.
4249
/// </summary>

src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Diagnostics/McpDiagnosticEventListener.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ public class McpDiagnosticEventListener : IMcpDiagnosticEventListener
2222

2323
public virtual IDisposable UpdateTools() => EmptyScope;
2424

25+
public virtual void ToolCreationFailed(string toolName, Exception exception)
26+
{
27+
}
28+
2529
public virtual void ValidationErrors(IReadOnlyList<IError> errors)
2630
{
2731
}

src/HotChocolate/Adapters/src/Adapters.Mcp.Core/McpStorageObserver.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,18 @@ private async Task RebuildToolsAsync(CancellationToken cancellationToken)
271271
continue;
272272
}
273273

274-
tools.Add(toolDefinition.Name, _toolFactory.CreateTool(toolDefinition));
274+
try
275+
{
276+
tools.Add(toolDefinition.Name, _toolFactory.CreateTool(toolDefinition));
277+
}
278+
catch (Exception exception)
279+
{
280+
_diagnosticEvents.ToolCreationFailed(toolDefinition.Name, exception);
281+
// Treat a definition the tool factory cannot build a tool for like an
282+
// invalid one, so a single broken definition does not take down the
283+
// remaining tools.
284+
invalidFallbacks.TryAdd(toolDefinition.Name, toolDefinition);
285+
}
275286
}
276287

277288
// For names with no valid definition at all, surface the first invalid one so calls

src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/IntegrationTestBase.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,47 @@ await storage.AddOrUpdateToolAsync(
972972
Assert.Equal("The field `doesNotExist2` does not exist on the type `Query`.", secondError.Message));
973973
}
974974

975+
[Fact]
976+
public async Task ListTools_InitializeToolsToolCreationFails_SurfacesBothToolsAndLogsFailure()
977+
{
978+
// arrange
979+
var storage = new TestMcpStorage();
980+
// A document that passes validation but that the tool factory cannot build a tool
981+
// for: the operation is anonymous, so no tool title can be derived from it.
982+
await storage.AddOrUpdateToolAsync(
983+
new OperationToolDefinition(Utf8GraphQLParser.Parse("{ books { title } }"))
984+
{
985+
Name = "failing"
986+
},
987+
TestContext.Current.CancellationToken);
988+
await storage.AddOrUpdateToolAsync(
989+
new OperationToolDefinition(
990+
Utf8GraphQLParser.Parse("query Valid { books { title } }")),
991+
TestContext.Current.CancellationToken);
992+
var listener = new TestMcpDiagnosticEventListener();
993+
var server = await CreateTestServerAsync(storage, diagnosticEventListener: listener);
994+
var mcpClient = await CreateMcpClientAsync(server.CreateClient());
995+
996+
// act
997+
var listResult = await mcpClient.ListToolsAsync(
998+
cancellationToken: TestContext.Current.CancellationToken);
999+
var callResult = await mcpClient.CallToolAsync(
1000+
"failing",
1001+
cancellationToken: TestContext.Current.CancellationToken);
1002+
1003+
// assert
1004+
Assert.Equal(2, listResult.Count);
1005+
Assert.Contains(listResult, tool => tool.Name == "valid");
1006+
Assert.Contains(listResult, tool => tool.Name == "failing");
1007+
var (toolName, _) = Assert.Single(listener.ToolCreationFailureLog);
1008+
Assert.Equal("failing", toolName);
1009+
Assert.Equal(true, callResult.IsError);
1010+
var content = Assert.Single(callResult.Content);
1011+
Assert.Equal(
1012+
"The tool 'failing' is currently unavailable.",
1013+
Assert.IsType<TextContentBlock>(content).Text);
1014+
}
1015+
9751016
[Fact]
9761017
public async Task CallTool_InvalidDocument_ReturnsErrorResult()
9771018
{
@@ -1485,8 +1526,15 @@ public static string GenerateToken()
14851526

14861527
public sealed class TestMcpDiagnosticEventListener : McpDiagnosticEventListener
14871528
{
1529+
public List<(string ToolName, Exception Exception)> ToolCreationFailureLog { get; } = [];
1530+
14881531
public List<IError> ValidationErrorLog { get; } = [];
14891532

1533+
public override void ToolCreationFailed(string toolName, Exception exception)
1534+
{
1535+
ToolCreationFailureLog.Add((toolName, exception));
1536+
}
1537+
14901538
public override void ValidationErrors(IReadOnlyList<IError> errors)
14911539
{
14921540
ValidationErrorLog.AddRange(errors);

0 commit comments

Comments
 (0)