Skip to content
Draft
8 changes: 5 additions & 3 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpSession.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using ModelContextProtocol.Server;
using ModelContextProtocol.Core;
using ModelContextProtocol.Server;
using System.Diagnostics;
using System.Security.Claims;

Expand All @@ -16,7 +17,7 @@ internal sealed class StreamableHttpSession(
private readonly object _stateLock = new();

private int _getRequestStarted;
private readonly CancellationTokenSource _disposeCts = new();
private CancellationTokenSource _disposeCts = new();

public string Id => sessionId;
public StreamableHttpServerTransport Transport => transport;
Expand Down Expand Up @@ -124,7 +125,8 @@ public async ValueTask DisposeAsync()
{
sessionManager.DecrementIdleSessionCount();
}
_disposeCts.Dispose();

CanceledTokenSource.Defuse(ref _disposeCts);
}
}

Expand Down
65 changes: 51 additions & 14 deletions src/ModelContextProtocol.Core/AIContentExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
#if !NET
using System.Buffers.Text;
using System.Runtime.InteropServices;
#endif
using System.Text.Json;
using System.Text.Json.Nodes;

Expand Down Expand Up @@ -263,10 +262,12 @@ public static IList<PromptMessage> ToPromptMessages(this ChatMessage chatMessage
AIContent? ac = content switch
{
TextContentBlock textContent => new TextContent(textContent.Text),

Utf8TextContentBlock utf8TextContent => new TextContent(utf8TextContent.Text),

ImageContentBlock imageContent => new DataContent(Convert.FromBase64String(imageContent.Data), imageContent.MimeType),
ImageContentBlock imageContent => new DataContent(imageContent.DecodedData, imageContent.MimeType),

AudioContentBlock audioContent => new DataContent(Convert.FromBase64String(audioContent.Data), audioContent.MimeType),
AudioContentBlock audioContent => new DataContent(audioContent.DecodedData, audioContent.MimeType),

EmbeddedResourceBlock resourceContent => resourceContent.Resource.ToAIContent(),

Expand All @@ -275,7 +276,9 @@ public static IList<PromptMessage> ToPromptMessages(this ChatMessage chatMessage

ToolResultContentBlock toolResult => new FunctionResultContent(
toolResult.ToolUseId,
toolResult.Content.Count == 1 ? toolResult.Content[0].ToAIContent() : toolResult.Content.Select(c => c.ToAIContent()).OfType<AIContent>().ToList())
toolResult.StructuredContent is JsonElement structured ? structured :
toolResult.Content.Count == 1 ? toolResult.Content[0].ToAIContent() :
toolResult.Content.Select(c => c.ToAIContent()).OfType<AIContent>().ToList())
{
Exception = toolResult.IsError is true ? new() : null,
},
Expand Down Expand Up @@ -307,7 +310,7 @@ public static AIContent ToAIContent(this ResourceContents content)

AIContent ac = content switch
{
BlobResourceContents blobResource => new DataContent(Convert.FromBase64String(blobResource.Blob), blobResource.MimeType ?? "application/octet-stream"),
BlobResourceContents blobResource => new DataContent(blobResource.DecodedData, blobResource.MimeType ?? "application/octet-stream"),
TextResourceContents textResource => new TextContent(textResource.Text),
_ => throw new NotSupportedException($"Resource type '{content.GetType().Name}' is not supported.")
};
Expand Down Expand Up @@ -380,21 +383,25 @@ public static ContentBlock ToContentBlock(this AIContent content)

DataContent dataContent when dataContent.HasTopLevelMediaType("image") => new ImageContentBlock
{
Data = dataContent.Base64Data.ToString(),
Data = MemoryMarshal.TryGetArray(dataContent.Base64Data, out ArraySegment<char> segment)
? new string(segment.Array!, segment.Offset, segment.Count)
: new string(dataContent.Base64Data.ToArray()),
MimeType = dataContent.MediaType,
},

DataContent dataContent when dataContent.HasTopLevelMediaType("audio") => new AudioContentBlock
{
Data = dataContent.Base64Data.ToString(),
Data = MemoryMarshal.TryGetArray(dataContent.Base64Data, out ArraySegment<char> segment)
? new string(segment.Array!, segment.Offset, segment.Count)
: new string(dataContent.Base64Data.ToArray()),
MimeType = dataContent.MediaType,
},

DataContent dataContent => new EmbeddedResourceBlock
{
Resource = new BlobResourceContents
{
Blob = dataContent.Base64Data.ToString(),
DecodedData = dataContent.Data,
MimeType = dataContent.MediaType,
Uri = string.Empty,
}
Expand All @@ -414,21 +421,51 @@ public static ContentBlock ToContentBlock(this AIContent content)
Content =
resultContent.Result is AIContent c ? [c.ToContentBlock()] :
resultContent.Result is IEnumerable<AIContent> ec ? [.. ec.Select(c => c.ToContentBlock())] :
[new TextContentBlock { Text = JsonSerializer.Serialize(content, McpJsonUtilities.DefaultOptions.GetTypeInfo<object>()) }],
StructuredContent = resultContent.Result is JsonElement je ? je : null,
[new TextContentBlock { Text = "" }],
StructuredContent =
resultContent.Result is JsonElement je ? je :
resultContent.Result is null ? null :
JsonSerializer.SerializeToElement(resultContent.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo<object>()),
},

_ => new TextContentBlock
_ => CreateJsonResourceContentBlock(content)
};

static ContentBlock CreateJsonResourceContentBlock(AIContent content)
{
byte[] jsonUtf8 = JsonSerializer.SerializeToUtf8Bytes(content, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));

#if NET
int maxLength = Base64.GetMaxEncodedToUtf8Length(jsonUtf8.Length);
#else
int maxLength = ((jsonUtf8.Length + 2) / 3) * 4;
#endif

byte[] base64 = new byte[maxLength];
if (Base64.EncodeToUtf8(jsonUtf8, base64, out _, out int bytesWritten) != System.Buffers.OperationStatus.Done)
{
Text = JsonSerializer.Serialize(content, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))),
throw new InvalidOperationException("Failed to base64-encode JSON payload.");
}
};

ReadOnlyMemory<byte> blob = base64.AsMemory(0, bytesWritten);

return new EmbeddedResourceBlock
{
Resource = new BlobResourceContents
{
Uri = string.Empty,
MimeType = "application/json",
BlobUtf8 = blob,
},
};
}

contentBlock.Meta = content.AdditionalProperties?.ToJsonObject();

return contentBlock;
}


private sealed class ToolAIFunctionDeclaration(Tool tool) : AIFunctionDeclaration
{
public override string Name => tool.Name;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Buffers.Text;
#endif
using System.Diagnostics.CodeAnalysis;
using ModelContextProtocol.Internal;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
Expand Down Expand Up @@ -581,8 +582,9 @@ private async Task PerformDynamicClientRegistrationAsync(
Scope = GetScopeParameter(protectedResourceMetadata),
};

var requestJson = JsonSerializer.Serialize(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
using var requestContent = new StringContent(requestJson, Encoding.UTF8, "application/json");
using var requestContent = new JsonTypeInfoHttpContent<DynamicClientRegistrationRequest>(
registrationRequest,
McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);

using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.RegistrationEndpoint)
{
Expand Down
44 changes: 44 additions & 0 deletions src/ModelContextProtocol.Core/CanceledTokenSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Diagnostics.CodeAnalysis;

namespace ModelContextProtocol.Core;

/// <summary>
/// A <see cref="CancellationTokenSource"/> that is already canceled.
/// Disposal is a no-op.
/// </summary>
public sealed class CanceledTokenSource : CancellationTokenSource
{
/// <summary>
/// Gets a singleton instance of a canceled token source.
/// </summary>
public static readonly CanceledTokenSource Instance = new();

private CanceledTokenSource()
=> Cancel();

/// <inheritdoc/>
protected override void Dispose(bool disposing)
{
// No-op
}

/// <summary>
/// Defuses the given <see cref="CancellationTokenSource"/> by optionally canceling it
/// and replacing it with the singleton canceled instance.
/// The original token source is left for garbage collection and finalization provided
/// there are no other references to it outstanding if <paramref name="dispose"/> is false.
/// </summary>
/// <param name="cts"> The token source to pseudo-dispose. May be null.</param>
/// <param name="cancel"> Whether to cancel the token source before pseudo-disposing it.</param>
/// <param name="dispose"> Whether to call Dispose on the token source.</param>
[SuppressMessage("Design", "CA1062:Validate arguments of public methods")]
public static void Defuse(ref CancellationTokenSource cts, bool cancel = true, bool dispose = false)
{
// don't null check; allow replacing null, allow throw on attempt to call Cancel
var orig = cts;
if (cancel) orig.Cancel();
Interlocked.Exchange(ref cts, Instance);
// presume the GC will finalize and dispose the original CTS as needed
if (dispose) orig.Dispose();
}
}
23 changes: 21 additions & 2 deletions src/ModelContextProtocol.Core/Client/McpClientTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,27 @@ result.StructuredContent is null &&
case 1 when result.Content[0].ToAIContent() is { } aiContent:
return aiContent;

case > 1 when result.Content.Select(c => c.ToAIContent()).ToArray() is { } aiContents && aiContents.All(static c => c is not null):
return aiContents;
case > 1:
AIContent[] aiContents = new AIContent[result.Content.Count];
bool allConverted = true;

for (int i = 0; i < aiContents.Length; i++)
{
if (result.Content[i].ToAIContent() is not { } c)
{
allConverted = false;
break;
}

aiContents[i] = c;
}

if (allConverted)
{
return aiContents;
}

break;
}
}

Expand Down
7 changes: 3 additions & 4 deletions src/ModelContextProtocol.Core/Client/McpHttpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,9 @@ internal virtual async Task<HttpResponseMessage> SendAsync(HttpRequestMessage re
#if NET
return JsonContent.Create(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
#else
return new StringContent(
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),
Encoding.UTF8,
"application/json"
return new ModelContextProtocol.Internal.JsonTypeInfoHttpContent<JsonRpcMessage>(
message,
McpJsonUtilities.JsonContext.Default.JsonRpcMessage
);
#endif
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Core;
using ModelContextProtocol.Protocol;
using System.Diagnostics;
using System.Net.Http.Headers;
Expand All @@ -18,7 +19,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
private readonly HttpClientTransportOptions _options;
private readonly Uri _sseEndpoint;
private Uri? _messageEndpoint;
private readonly CancellationTokenSource _connectionCts;
private CancellationTokenSource _connectionCts;
private Task? _receiveTask;
private readonly ILogger _logger;
private readonly TaskCompletionSource<bool> _connectionEstablished;
Expand Down Expand Up @@ -114,7 +115,7 @@ private async Task CloseAsync()
}
finally
{
_connectionCts.Dispose();
CanceledTokenSource.Defuse(ref _connectionCts, dispose: true);
}
}
finally
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace ModelContextProtocol.Client;
/// <summary>Provides the client side of a stdio-based session transport.</summary>
internal sealed class StdioClientSessionTransport(
StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, ILoggerFactory? loggerFactory) :
StreamClientSessionTransport(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory)
StreamClientSessionTransport(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, endpointName, loggerFactory)
{
private readonly StdioClientTransportOptions _options = options;
private readonly Process _process = process;
Expand Down
Loading