Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,18 @@ private async Task InitializePromptsAsync(CancellationToken cancellationToken)
var prompts = ImmutableDictionary.CreateBuilder<string, (Prompt, ImmutableArray<PromptMessage>)>();
using var scope = _diagnosticEvents.InitializePrompts();

foreach (var promptDefinition in await _storage.GetPromptDefinitionsAsync(cancellationToken))
var definitions = (await _storage.GetPromptDefinitionsAsync(cancellationToken))
.OrderBy(p => p.Name, StringComparer.Ordinal);

foreach (var promptDefinition in definitions)
{
// When multiple definitions share a name (e.g. across collections published to the
// same stage), the first one wins after ordering by name.
Comment thread
glen-84 marked this conversation as resolved.
Outdated
if (prompts.ContainsKey(promptDefinition.Name))
{
continue;
}

var prompt = PromptFactory.CreatePrompt(promptDefinition);
prompts.Add(promptDefinition.Name, prompt);
}
Expand All @@ -107,8 +117,18 @@ private async Task InitializeToolsAsync(CancellationToken cancellationToken)
var tools = ImmutableDictionary.CreateBuilder<string, OperationTool>();
using var scope = _diagnosticEvents.InitializeTools();

foreach (var toolDefinition in await _storage.GetOperationToolDefinitionsAsync(cancellationToken))
var definitions = (await _storage.GetOperationToolDefinitionsAsync(cancellationToken))
.OrderBy(t => t.Name, StringComparer.Ordinal);

foreach (var toolDefinition in definitions)
{
// When multiple definitions share a name (e.g. across collections published to the
// same stage), the first one wins after ordering by name.
if (tools.ContainsKey(toolDefinition.Name))
Comment thread
glen-84 marked this conversation as resolved.
Outdated
{
continue;
}

var validationResult = s_documentValidator.Validate(_schema, toolDefinition.Document);

if (validationResult.HasErrors)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,76 @@ public async Task GetPrompt_Valid_ReturnsPrompt()
.MatchSnapshot(extension: ".json");
}

[Fact]
public async Task ListPrompts_DuplicateName_DeduplicatesAndDoesNotCrash()
{
// arrange
var storage = new MultiCollectionMcpStorage(
prompts:
[
new PromptDefinition("code_review")
{
Title = "First Code Review",
Messages =
[
new PromptMessageDefinition(
RoleDefinition.User,
new TextContentBlockDefinition("Review (first)."))
]
},
new PromptDefinition("code_review")
{
Title = "Second Code Review",
Messages =
[
new PromptMessageDefinition(
RoleDefinition.User,
new TextContentBlockDefinition("Review (second)."))
]
}
]);
var server = await CreateTestServerAsync(storage);
var mcpClient = await CreateMcpClientAsync(server.CreateClient());

// act
var prompts = await mcpClient.ListPromptsAsync();

// assert
var prompt = Assert.Single(prompts);
Assert.Equal("code_review", prompt.Name);
Assert.Equal("First Code Review", prompt.ProtocolPrompt.Title);
}

[Fact]
public async Task ListTools_DuplicateName_DeduplicatesAndDoesNotCrash()
{
// arrange
var storage = new MultiCollectionMcpStorage(
tools:
[
new OperationToolDefinition(
Utf8GraphQLParser.Parse("query GetBooks { books { title } }"))
{
Title = "First GetBooks"
},
new OperationToolDefinition(
Utf8GraphQLParser.Parse("query GetBooks { books { title } }"))
{
Title = "Second GetBooks"
}
]);
var server = await CreateTestServerAsync(storage);
var mcpClient = await CreateMcpClientAsync(server.CreateClient());

// act
var tools = await mcpClient.ListToolsAsync();

// assert
var tool = Assert.Single(tools);
Assert.Equal("get_books", tool.Name);
Assert.Equal("First GetBooks", tool.Title);
}

[Fact]
public async Task GetPrompt_Missing_ThrowsException()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using HotChocolate.Adapters.Mcp.Storage;

namespace HotChocolate.Adapters.Mcp;

/// <summary>
/// Test storage that returns the supplied definitions verbatim, allowing duplicate
/// names. This simulates a production scenario where multiple collections are
/// published to the same stage and surface overlapping definitions.
/// </summary>
internal sealed class MultiCollectionMcpStorage : IMcpStorage
{
private readonly IReadOnlyList<PromptDefinition> _prompts;
private readonly IReadOnlyList<OperationToolDefinition> _tools;

public MultiCollectionMcpStorage(
IReadOnlyList<PromptDefinition>? prompts = null,
IReadOnlyList<OperationToolDefinition>? tools = null)
{
_prompts = prompts ?? [];
_tools = tools ?? [];
}

public ValueTask<IEnumerable<OperationToolDefinition>> GetOperationToolDefinitionsAsync(
CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IEnumerable<OperationToolDefinition>>(_tools);

public ValueTask<IEnumerable<PromptDefinition>> GetPromptDefinitionsAsync(
CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IEnumerable<PromptDefinition>>(_prompts);

public IDisposable Subscribe(IObserver<OperationToolStorageEventArgs> observer)
=> NoOpSubscription.Instance;

public IDisposable Subscribe(IObserver<PromptStorageEventArgs> observer)
=> NoOpSubscription.Instance;

private sealed class NoOpSubscription : IDisposable
{
public static readonly NoOpSubscription Instance = new();

public void Dispose()
{
}
}
}
Loading