Skip to content

Commit 8d74808

Browse files
committed
Prepare 0.1.1 release
1 parent 98ae8f6 commit 8d74808

File tree

11 files changed

+301
-29
lines changed

11 files changed

+301
-29
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ If no new rule is detected -> do not update the file.
190190
- Reusable library design over app-specific glue
191191
- Search + execute flows covered by automated tests
192192
- Clean root packaging and CI setup
193+
- Direct fixes over preserving legacy compatibility paths when cleanup or review-driven corrections are requested
193194

194195
### Dislikes
195196

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<AnalysisLevel>latest-recommended</AnalysisLevel>
1212
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
1313
<NoWarn>$(NoWarn);CS1591;CA1707;CA1848;CA1859;CA1873</NoWarn>
14-
<Version>0.1.0</Version>
14+
<Version>0.1.1</Version>
1515
<PackageVersion>$(Version)</PackageVersion>
1616
</PropertyGroup>
1717

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ During `BuildIndexAsync()` the gateway:
238238
- generates embeddings only for tools that are missing in the store
239239
- upserts the newly generated vectors back into the store
240240

241-
This avoids recalculating tool embeddings on every rebuild while still refreshing them automatically when the descriptor document changes. Query embeddings are still generated at search time from the registered `IEmbeddingGenerator<string, Embedding<float>>`.
241+
This avoids recalculating tool embeddings on every rebuild while still refreshing them automatically when the descriptor document changes. Stored vectors are scoped to both the descriptor hash and the resolved embedding-generator fingerprint, so changing the provider or model automatically forces regeneration. Query embeddings are still generated at search time from the registered `IEmbeddingGenerator<string, Embedding<float>>`.
242242

243243
## Supported Sources
244244

src/ManagedCode.MCPGateway/McpGateway.cs

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public sealed class McpGateway(
4444
private const string SourceLoadFailedMessageTemplate = "Failed to load tools from source '{0}': {1}";
4545
private const string DuplicateToolIdMessageTemplate = "Skipped duplicate tool id '{0}'.";
4646
private const string EmbeddingCountMismatchMessageTemplate = "Embedding generation returned {0} vectors for {1} tools.";
47-
private const string EmbeddingGeneratorMissingMessage = "No keyed or unkeyed IEmbeddingGenerator<string, Embedding<float>> is registered. Lexical fallback only.";
47+
private const string EmbeddingGeneratorMissingMessage = "No keyed or unkeyed IEmbeddingGenerator<string, Embedding<float>> is registered. Stored tool embeddings may be reused, but search falls back lexically without a query embedding generator.";
4848
private const string EmbeddingFailedMessageTemplate = "Embedding generation failed: {0}";
4949
private const string EmbeddingStoreLoadFailedMessageTemplate = "Loading stored tool embeddings failed: {0}";
5050
private const string EmbeddingStoreSaveFailedMessageTemplate = "Persisting generated tool embeddings failed: {0}";
@@ -80,6 +80,8 @@ public sealed class McpGateway(
8080
private const string ContextPrefix = "context: ";
8181
private const string PluralSuffixIes = "ies";
8282
private const string PluralSuffixEs = "es";
83+
private const string EmbeddingGeneratorFingerprintUnknownComponent = "unknown";
84+
private const string EmbeddingGeneratorFingerprintComponentSeparator = "\n";
8385

8486
private static readonly char[] TokenSeparators =
8587
[
@@ -285,16 +287,21 @@ public async Task<McpGatewayIndexBuildResult> BuildIndexAsync(CancellationToken
285287
}
286288

287289
var vectorizedToolCount = 0;
290+
var isVectorSearchEnabled = false;
288291
if (entries.Count > 0)
289292
{
290293
await using var embeddingGeneratorLease = ResolveEmbeddingGenerator();
291294
await using var embeddingStoreLease = ResolveToolEmbeddingStore();
292295
var embeddingGenerator = embeddingGeneratorLease.Generator;
296+
var embeddingGeneratorFingerprint = ResolveEmbeddingGeneratorFingerprint(embeddingGenerator);
293297
var embeddingStore = embeddingStoreLease.Store;
294298
var storeCandidates = entries
295299
.Select((entry, index) => new ToolEmbeddingCandidate(
296300
index,
297-
new McpGatewayToolEmbeddingLookup(entry.Descriptor.ToolId, ComputeDocumentHash(entry.Document)),
301+
new McpGatewayToolEmbeddingLookup(
302+
entry.Descriptor.ToolId,
303+
ComputeDocumentHash(entry.Document),
304+
embeddingGeneratorFingerprint),
298305
entry.Descriptor.SourceId,
299306
entry.Descriptor.ToolName))
300307
.ToList();
@@ -306,13 +313,12 @@ public async Task<McpGatewayIndexBuildResult> BuildIndexAsync(CancellationToken
306313
var storedEmbeddings = await embeddingStore.GetAsync(
307314
storeCandidates.Select(static candidate => candidate.Lookup).ToList(),
308315
cancellationToken);
309-
var storedEmbeddingsByLookup = storedEmbeddings
310-
.GroupBy(static embedding => new McpGatewayToolEmbeddingLookup(embedding.ToolId, embedding.DocumentHash))
311-
.ToDictionary(static group => group.Key, static group => group.Last());
312316

313317
foreach (var candidate in storeCandidates)
314318
{
315-
if (storedEmbeddingsByLookup.TryGetValue(candidate.Lookup, out var storedEmbedding))
319+
var storedEmbedding = storedEmbeddings.LastOrDefault(embedding =>
320+
MatchesStoredEmbedding(candidate.Lookup, embedding));
321+
if (storedEmbedding is not null)
316322
{
317323
ApplyEmbedding(entries, candidate.Index, storedEmbedding.Vector, ref vectorizedToolCount);
318324
}
@@ -331,6 +337,13 @@ public async Task<McpGatewayIndexBuildResult> BuildIndexAsync(CancellationToken
331337
.Where(candidate => entries[candidate.Index].Magnitude <= double.Epsilon)
332338
.ToList();
333339

340+
if (embeddingGenerator is null && vectorizedToolCount > 0)
341+
{
342+
diagnostics.Add(new McpGatewayDiagnostic(
343+
EmbeddingGeneratorMissingDiagnosticCode,
344+
EmbeddingGeneratorMissingMessage));
345+
}
346+
334347
if (missingCandidates.Count > 0)
335348
{
336349
try
@@ -355,6 +368,7 @@ public async Task<McpGatewayIndexBuildResult> BuildIndexAsync(CancellationToken
355368
candidate.SourceId,
356369
candidate.ToolName,
357370
candidate.Lookup.DocumentHash,
371+
candidate.Lookup.EmbeddingGeneratorFingerprint,
358372
vector));
359373
}
360374
}
@@ -400,14 +414,16 @@ public async Task<McpGatewayIndexBuildResult> BuildIndexAsync(CancellationToken
400414
_logger.LogWarning(ex, EmbeddingGenerationFailedLogMessage);
401415
}
402416
}
417+
418+
isVectorSearchEnabled = vectorizedToolCount > 0 && embeddingGenerator is not null;
403419
}
404420

405421
var snapshot = new ToolCatalogSnapshot(
406422
entries
407423
.OrderBy(static item => item.Descriptor.ToolName, StringComparer.OrdinalIgnoreCase)
408424
.ThenBy(static item => item.Descriptor.SourceId, StringComparer.OrdinalIgnoreCase)
409425
.ToList(),
410-
vectorizedToolCount > 0);
426+
isVectorSearchEnabled);
411427

412428
lock (_gate)
413429
{
@@ -1002,6 +1018,37 @@ private static bool ApplyEmbedding(
10021018
private static string ComputeDocumentHash(string value)
10031019
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
10041020

1021+
private static bool MatchesStoredEmbedding(
1022+
McpGatewayToolEmbeddingLookup lookup,
1023+
McpGatewayToolEmbedding embedding)
1024+
=> string.Equals(embedding.ToolId, lookup.ToolId, StringComparison.OrdinalIgnoreCase)
1025+
&& string.Equals(embedding.DocumentHash, lookup.DocumentHash, StringComparison.Ordinal)
1026+
&& (lookup.EmbeddingGeneratorFingerprint is null
1027+
|| string.Equals(
1028+
embedding.EmbeddingGeneratorFingerprint,
1029+
lookup.EmbeddingGeneratorFingerprint,
1030+
StringComparison.Ordinal));
1031+
1032+
private static string? ResolveEmbeddingGeneratorFingerprint(
1033+
IEmbeddingGenerator<string, Embedding<float>>? embeddingGenerator)
1034+
{
1035+
if (embeddingGenerator is null)
1036+
{
1037+
return null;
1038+
}
1039+
1040+
var metadata = embeddingGenerator.GetService(typeof(EmbeddingGeneratorMetadata)) as EmbeddingGeneratorMetadata;
1041+
var generatorTypeName = embeddingGenerator.GetType().FullName ?? embeddingGenerator.GetType().Name;
1042+
1043+
return ComputeDocumentHash(string.Join(
1044+
EmbeddingGeneratorFingerprintComponentSeparator,
1045+
metadata?.ProviderName ?? EmbeddingGeneratorFingerprintUnknownComponent,
1046+
metadata?.ProviderUri?.AbsoluteUri ?? EmbeddingGeneratorFingerprintUnknownComponent,
1047+
metadata?.DefaultModelId ?? EmbeddingGeneratorFingerprintUnknownComponent,
1048+
metadata?.DefaultModelDimensions?.ToString(CultureInfo.InvariantCulture) ?? EmbeddingGeneratorFingerprintUnknownComponent,
1049+
generatorTypeName ?? EmbeddingGeneratorFingerprintUnknownComponent));
1050+
}
1051+
10051052
private static string BuildEffectiveSearchQuery(McpGatewaySearchRequest request)
10061053
{
10071054
List<string> parts = [];

src/ManagedCode.MCPGateway/McpGatewayInMemoryToolEmbeddingStore.cs

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,22 @@ namespace ManagedCode.MCPGateway;
55

66
public sealed class McpGatewayInMemoryToolEmbeddingStore : IMcpGatewayToolEmbeddingStore
77
{
8-
private readonly ConcurrentDictionary<McpGatewayToolEmbeddingLookup, McpGatewayToolEmbedding> _embeddings = new();
8+
private readonly ConcurrentDictionary<StoreKey, McpGatewayToolEmbedding> _embeddings = new();
99

1010
public Task<IReadOnlyList<McpGatewayToolEmbedding>> GetAsync(
1111
IReadOnlyList<McpGatewayToolEmbeddingLookup> lookups,
1212
CancellationToken cancellationToken = default)
1313
{
1414
cancellationToken.ThrowIfCancellationRequested();
1515

16-
var results = lookups
17-
.Where(_embeddings.ContainsKey)
18-
.Select(lookup => Clone(_embeddings[lookup]))
19-
.ToList();
16+
var results = new List<McpGatewayToolEmbedding>(lookups.Count);
17+
foreach (var lookup in lookups)
18+
{
19+
if (TryGetEmbedding(lookup, out var embedding))
20+
{
21+
results.Add(Clone(embedding));
22+
}
23+
}
2024

2125
return Task.FromResult<IReadOnlyList<McpGatewayToolEmbedding>>(results);
2226
}
@@ -30,15 +34,67 @@ public Task UpsertAsync(
3034
foreach (var embedding in embeddings)
3135
{
3236
var clone = Clone(embedding);
33-
_embeddings[new McpGatewayToolEmbeddingLookup(clone.ToolId, clone.DocumentHash)] = clone;
37+
_embeddings[StoreKey.FromEmbedding(clone)] = clone;
3438
}
3539

3640
return Task.CompletedTask;
3741
}
3842

43+
private bool TryGetEmbedding(
44+
McpGatewayToolEmbeddingLookup lookup,
45+
out McpGatewayToolEmbedding embedding)
46+
{
47+
var storeKey = StoreKey.FromLookup(lookup);
48+
if (lookup.EmbeddingGeneratorFingerprint is not null)
49+
{
50+
return _embeddings.TryGetValue(storeKey, out embedding!);
51+
}
52+
53+
foreach (var pair in _embeddings)
54+
{
55+
if (pair.Key.Matches(storeKey))
56+
{
57+
embedding = pair.Value;
58+
return true;
59+
}
60+
}
61+
62+
embedding = default!;
63+
return false;
64+
}
65+
3966
private static McpGatewayToolEmbedding Clone(McpGatewayToolEmbedding embedding)
4067
=> embedding with
4168
{
4269
Vector = [.. embedding.Vector]
4370
};
71+
72+
private static string NormalizeToolId(string toolId) => toolId.ToUpperInvariant();
73+
74+
private readonly record struct StoreKey(
75+
string NormalizedToolId,
76+
string DocumentHash,
77+
string? EmbeddingGeneratorFingerprint)
78+
{
79+
public static StoreKey FromLookup(McpGatewayToolEmbeddingLookup lookup)
80+
=> new(
81+
NormalizeToolId(lookup.ToolId),
82+
lookup.DocumentHash,
83+
lookup.EmbeddingGeneratorFingerprint);
84+
85+
public static StoreKey FromEmbedding(McpGatewayToolEmbedding embedding)
86+
=> new(
87+
NormalizeToolId(embedding.ToolId),
88+
embedding.DocumentHash,
89+
embedding.EmbeddingGeneratorFingerprint);
90+
91+
public bool Matches(StoreKey other)
92+
=> string.Equals(NormalizedToolId, other.NormalizedToolId, StringComparison.Ordinal)
93+
&& string.Equals(DocumentHash, other.DocumentHash, StringComparison.Ordinal)
94+
&& (other.EmbeddingGeneratorFingerprint is null
95+
|| string.Equals(
96+
EmbeddingGeneratorFingerprint,
97+
other.EmbeddingGeneratorFingerprint,
98+
StringComparison.Ordinal));
99+
}
44100
}

src/ManagedCode.MCPGateway/Models/McpGatewayToolEmbedding.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ public sealed record McpGatewayToolEmbedding(
55
string SourceId,
66
string ToolName,
77
string DocumentHash,
8+
string? EmbeddingGeneratorFingerprint,
89
float[] Vector);

src/ManagedCode.MCPGateway/Models/McpGatewayToolEmbeddingLookup.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ namespace ManagedCode.MCPGateway;
22

33
public sealed record McpGatewayToolEmbeddingLookup(
44
string ToolId,
5-
string DocumentHash);
5+
string DocumentHash,
6+
string? EmbeddingGeneratorFingerprint = null);

tests/ManagedCode.MCPGateway.Tests/Search/McpGatewayInMemoryToolEmbeddingStoreTests.cs

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,22 @@ await store.UpsertAsync(
1313
"local",
1414
"github_search_issues",
1515
"hash-1",
16+
"fingerprint-a",
1617
[1f, 2f, 3f]),
1718
new McpGatewayToolEmbedding(
1819
"local:weather_search_forecast",
1920
"local",
2021
"weather_search_forecast",
2122
"hash-2",
23+
"fingerprint-a",
2224
[4f, 5f, 6f])
2325
]);
2426

2527
var result = await store.GetAsync(
2628
[
27-
new McpGatewayToolEmbeddingLookup("local:weather_search_forecast", "hash-2"),
28-
new McpGatewayToolEmbeddingLookup("local:missing", "hash-3"),
29-
new McpGatewayToolEmbeddingLookup("local:github_search_issues", "hash-1")
29+
new McpGatewayToolEmbeddingLookup("local:weather_search_forecast", "hash-2", "fingerprint-a"),
30+
new McpGatewayToolEmbeddingLookup("local:missing", "hash-3", "fingerprint-a"),
31+
new McpGatewayToolEmbeddingLookup("local:github_search_issues", "hash-1", "fingerprint-a")
3032
]);
3133

3234
await Assert.That(result.Count).IsEqualTo(2);
@@ -47,24 +49,54 @@ await store.UpsertAsync(
4749
"local",
4850
"github_search_issues",
4951
"hash-1",
52+
"fingerprint-a",
5053
inputVector)
5154
]);
5255

5356
inputVector[0] = 99f;
5457

5558
var firstRead = await store.GetAsync(
5659
[
57-
new McpGatewayToolEmbeddingLookup("local:github_search_issues", "hash-1")
60+
new McpGatewayToolEmbeddingLookup("local:github_search_issues", "hash-1", "fingerprint-a")
5861
]);
5962
firstRead[0].Vector[1] = 77f;
6063

6164
var secondRead = await store.GetAsync(
6265
[
63-
new McpGatewayToolEmbeddingLookup("local:github_search_issues", "hash-1")
66+
new McpGatewayToolEmbeddingLookup("local:github_search_issues", "hash-1", "fingerprint-a")
6467
]);
6568

6669
await Assert.That(secondRead[0].Vector[0]).IsEqualTo(1f);
6770
await Assert.That(secondRead[0].Vector[1]).IsEqualTo(2f);
6871
await Assert.That(secondRead[0].Vector[2]).IsEqualTo(3f);
6972
}
73+
74+
[TUnit.Core.Test]
75+
public async Task GetAsync_TreatsToolIdsCaseInsensitivelyAndSupportsFingerprintFallback()
76+
{
77+
var store = new McpGatewayInMemoryToolEmbeddingStore();
78+
await store.UpsertAsync(
79+
[
80+
new McpGatewayToolEmbedding(
81+
"local:github_search_issues",
82+
"local",
83+
"github_search_issues",
84+
"hash-1",
85+
"fingerprint-a",
86+
[1f, 2f, 3f])
87+
]);
88+
89+
var fingerprintMatch = await store.GetAsync(
90+
[
91+
new McpGatewayToolEmbeddingLookup("LOCAL:GITHUB_SEARCH_ISSUES", "hash-1", "fingerprint-a")
92+
]);
93+
var fingerprintAgnosticMatch = await store.GetAsync(
94+
[
95+
new McpGatewayToolEmbeddingLookup("LOCAL:GITHUB_SEARCH_ISSUES", "hash-1")
96+
]);
97+
98+
await Assert.That(fingerprintMatch.Count).IsEqualTo(1);
99+
await Assert.That(fingerprintAgnosticMatch.Count).IsEqualTo(1);
100+
await Assert.That(fingerprintAgnosticMatch[0].ToolId).IsEqualTo("local:github_search_issues");
101+
}
70102
}

0 commit comments

Comments
 (0)