Skip to content

Commit d5c6b0d

Browse files
Add DistributedCacheEventStreamStore (#1136)
Co-authored-by: Stephen Halter <halter73@gmail.com>
1 parent 712068b commit d5c6b0d

12 files changed

+3045
-511
lines changed

Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
<ItemGroup>
1515
<PackageVersion Include="Microsoft.Extensions.AI" Version="$(MicrosoftExtensionsVersion)" />
1616
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="$(MicrosoftExtensionsVersion)" />
17+
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="$(System10Version)" />
18+
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="$(System10Version)" />
1719
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="$(System10Version)" />
1820
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(System10Version)" />
1921
</ItemGroup>

src/ModelContextProtocol/ModelContextProtocol.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
</ItemGroup>
2525

2626
<ItemGroup>
27+
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
2728
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
2829
</ItemGroup>
2930

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
// This is a shared source file included in both ModelContextProtocol and the test project.
5+
// Do not reference symbols internal to the core project, as they won't be available in tests.
6+
#if NET
7+
using System.Buffers;
8+
using System.Buffers.Text;
9+
using System.Diagnostics.CodeAnalysis;
10+
11+
#endif
12+
using System.Text;
13+
14+
namespace ModelContextProtocol.Server;
15+
16+
/// <summary>
17+
/// Provides methods for formatting and parsing event IDs used by <see cref="DistributedCacheEventStreamStore"/>.
18+
/// </summary>
19+
/// <remarks>
20+
/// Event IDs are formatted as "{base64(sessionId)}:{base64(streamId)}:{sequence}".
21+
/// </remarks>
22+
internal static class DistributedCacheEventIdFormatter
23+
{
24+
private const char Separator = ':';
25+
26+
/// <summary>
27+
/// Formats session ID, stream ID, and sequence number into an event ID string.
28+
/// </summary>
29+
public static string Format(string sessionId, string streamId, long sequence)
30+
{
31+
// Base64-encode session and stream IDs so the event ID can be parsed
32+
// even if the original IDs contain the ':' separator character
33+
var sessionBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(sessionId));
34+
var streamBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(streamId));
35+
return $"{sessionBase64}{Separator}{streamBase64}{Separator}{sequence}";
36+
}
37+
38+
/// <summary>
39+
/// Attempts to parse an event ID into its component parts.
40+
/// </summary>
41+
public static bool TryParse(string eventId, out string sessionId, out string streamId, out long sequence)
42+
{
43+
sessionId = string.Empty;
44+
streamId = string.Empty;
45+
sequence = 0;
46+
47+
#if NET
48+
ReadOnlySpan<char> eventIdSpan = eventId.AsSpan();
49+
Span<Range> partRanges = stackalloc Range[4];
50+
int rangeCount = eventIdSpan.Split(partRanges, Separator);
51+
if (rangeCount != 3)
52+
{
53+
return false;
54+
}
55+
56+
try
57+
{
58+
ReadOnlySpan<char> sessionBase64 = eventIdSpan[partRanges[0]];
59+
ReadOnlySpan<char> streamBase64 = eventIdSpan[partRanges[1]];
60+
ReadOnlySpan<char> sequenceSpan = eventIdSpan[partRanges[2]];
61+
62+
if (!TryDecodeBase64ToString(sessionBase64, out sessionId!) ||
63+
!TryDecodeBase64ToString(streamBase64, out streamId!))
64+
{
65+
return false;
66+
}
67+
68+
return long.TryParse(sequenceSpan, out sequence);
69+
}
70+
catch
71+
{
72+
return false;
73+
}
74+
#else
75+
var parts = eventId.Split(Separator);
76+
if (parts.Length != 3)
77+
{
78+
return false;
79+
}
80+
81+
try
82+
{
83+
sessionId = Encoding.UTF8.GetString(Convert.FromBase64String(parts[0]));
84+
streamId = Encoding.UTF8.GetString(Convert.FromBase64String(parts[1]));
85+
return long.TryParse(parts[2], out sequence);
86+
}
87+
catch
88+
{
89+
return false;
90+
}
91+
#endif
92+
}
93+
94+
#if NET
95+
private static bool TryDecodeBase64ToString(ReadOnlySpan<char> base64Chars, [NotNullWhen(true)] out string? result)
96+
{
97+
// Use a single buffer: base64 chars are ASCII (1:1 with UTF8 bytes),
98+
// and decoded data is always smaller than encoded, so we can decode in-place.
99+
int bufferLength = base64Chars.Length;
100+
Span<byte> buffer = bufferLength <= 256
101+
? stackalloc byte[bufferLength]
102+
: new byte[bufferLength];
103+
104+
Encoding.UTF8.GetBytes(base64Chars, buffer);
105+
106+
OperationStatus status = Base64.DecodeFromUtf8InPlace(buffer, out int bytesWritten);
107+
if (status != OperationStatus.Done)
108+
{
109+
result = null;
110+
return false;
111+
}
112+
113+
result = Encoding.UTF8.GetString(buffer[..bytesWritten]);
114+
return true;
115+
}
116+
#endif
117+
}

0 commit comments

Comments
 (0)