Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
59 changes: 59 additions & 0 deletions src/ModelContextProtocol/Logging/McpServerLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
namespace ModelContextProtocol.Logging;

/// <summary>
/// Delegate that allows receiving log details about request/response calls.
/// </summary>
public delegate void McpLogHandler(McpLogContext context);

/// <summary>
/// Class that provides context details about a given call, for logging purposes.
/// </summary>
public sealed class McpLogContext
{
/// <summary>
/// Gets <see cref="McpStatus"/> information about when (which moment in the workflow) this log was emitted.
/// </summary>
public McpStatus Status { get; init; }

/// <summary>
/// Gets information about the JSON message that was received/sent when this event happened.
/// </summary>
public required string Json { get; init; }

/// <summary>
/// If applicable, gets the <see cref="Exception"/> that happened when this message was processed.
/// </summary>
/// <remarks>This property is commonly associated with a <see cref="McpStatus.ErrorOccurred"/> status.</remarks>
public Exception? Exception { get; init; }

/// <summary>
/// Gets information about the method that was called.
/// </summary>
public string? Method { get; init; }

/// <summary>
/// Gets a <see cref="IServiceProvider"/> instance that allows you accessing instances registered for the application.
/// </summary>
public IServiceProvider? ServiceProvider { get; set; }
}

/// <summary>
/// Enum that defines possible events that might happen during a MCP messaging workflow.
/// </summary>
public enum McpStatus
{
/// <summary>
/// Specifies that the given message was received from a MCP Client.
/// </summary>
RequestReceived,

/// <summary>
/// Specifies that the MCP Server just sent this message back to the Client.
/// </summary>
ResponseSent,

/// <summary>
/// Specifies that an exception happened when trying to process a given request.
/// </summary>
ErrorOccurred
}
20 changes: 19 additions & 1 deletion src/ModelContextProtocol/Server/AIFunctionMcpServerPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.ComponentModel;
using System.Reflection;
using System.Text.Json;
using ModelContextProtocol.Logging;

namespace ModelContextProtocol.Server;

Expand Down Expand Up @@ -239,7 +240,24 @@ public override async ValueTask<GetPromptResult> GetAsync(
}
}

object? result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
object? result;
try
{
result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
}
catch (Exception e)
{
request.Server.ServerOptions.LogHandler?.Invoke(new()
{
Exception = e,
ServiceProvider = request.Services,
Json = JsonSerializer.Serialize(request.Params, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(GetPromptRequestParams))),
Status = McpStatus.ErrorOccurred,
Method = RequestMethods.PromptsGet
});

throw;
}

return result switch
{
Expand Down
23 changes: 21 additions & 2 deletions src/ModelContextProtocol/Server/AIFunctionMcpServerResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using ModelContextProtocol.Logging;

namespace ModelContextProtocol.Server;

Expand Down Expand Up @@ -380,8 +381,26 @@ private AIFunctionMcpServerResource(AIFunction function, ResourceTemplate resour
}
}

// Invoke the function.
object? result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
object? result;
try
{
// Invoke the function.
result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
}
catch (Exception e)
{
request.Server.ServerOptions.LogHandler?.Invoke(new()
{
Exception = e,
ServiceProvider = request.Services,
Json = JsonSerializer.Serialize(request.Params,
AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(ReadResourceRequestParams))),
Status = McpStatus.ErrorOccurred,
Method = RequestMethods.ResourcesRead
});

throw;
}

// And process the result.
return result switch
Expand Down
10 changes: 10 additions & 0 deletions src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
using ModelContextProtocol.Logging;

namespace ModelContextProtocol.Server;

Expand Down Expand Up @@ -277,6 +278,15 @@ public override async ValueTask<CallToolResponse> InvokeAsync(
}
catch (Exception e) when (e is not OperationCanceledException)
{
request.Server.ServerOptions.LogHandler?.Invoke(new()
{
Exception = e,
ServiceProvider = request.Services,
Json = JsonSerializer.Serialize(request.Params, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(CallToolRequestParams))),
Status = McpStatus.ErrorOccurred,
Method = RequestMethods.ToolsCall
});

string errorMessage = e is McpException ?
$"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
$"An error occurred invoking '{request.Params?.Name}'.";
Expand Down
72 changes: 62 additions & 10 deletions src/ModelContextProtocol/Server/McpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using ModelContextProtocol.Logging;

namespace ModelContextProtocol.Server;

Expand Down Expand Up @@ -236,9 +238,29 @@ await originalListResourcesHandler(request, cancellationToken).ConfigureAwait(fa
var originalListResourceTemplatesHandler = listResourceTemplatesHandler;
listResourceTemplatesHandler = async (request, cancellationToken) =>
{
ListResourceTemplatesResult result = originalListResourceTemplatesHandler is not null ?
await originalListResourceTemplatesHandler(request, cancellationToken).ConfigureAwait(false) :
new();
ListResourceTemplatesResult result;

try
{
result = originalListResourceTemplatesHandler is not null ?
await originalListResourceTemplatesHandler(request, cancellationToken).ConfigureAwait(false) :
new();
}
catch (Exception e)
{
request.Server.ServerOptions.LogHandler?.Invoke(new()
{
Exception = e,
ServiceProvider = request.Services,
Json = JsonSerializer.Serialize(request.Params,
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ListResourceTemplatesRequestParams))),
Status = McpStatus.ErrorOccurred,
Method = RequestMethods.ResourcesTemplatesList
});

throw;
}


if (request.Params?.Cursor is null)
{
Expand Down Expand Up @@ -470,6 +492,8 @@ private void ConfigureLogging(McpServerOptions options)
// Store the provided level.
if (request is not null)
{
InvokeLogHandler(RequestMethods.LoggingSetLevel, McpStatus.RequestReceived, request);

if (_loggingLevel is null)
{
Interlocked.CompareExchange(ref _loggingLevel, new(request.Level), null);
Expand All @@ -481,8 +505,11 @@ private void ConfigureLogging(McpServerOptions options)
// If a handler was provided, now delegate to it.
if (setLoggingLevelHandler is not null)
{
return InvokeHandlerAsync(setLoggingLevelHandler, request, destinationTransport, cancellationToken);
return InvokeHandlerAsync(RequestMethods.LoggingSetLevel, setLoggingLevelHandler, request,
destinationTransport, cancellationToken);
}

InvokeLogHandler(RequestMethods.LoggingSetLevel, McpStatus.ResponseSent, EmptyResult.Instance);

// Otherwise, consider it handled.
return new ValueTask<EmptyResult>(EmptyResult.Instance);
Expand All @@ -491,31 +518,47 @@ private void ConfigureLogging(McpServerOptions options)
McpJsonUtilities.JsonContext.Default.EmptyResult);
}

private ValueTask<TResult> InvokeHandlerAsync<TParams, TResult>(
private async ValueTask<TResult> InvokeHandlerAsync<TParams, TResult>(
string method,
Func<RequestContext<TParams>, CancellationToken, ValueTask<TResult>> handler,
TParams? args,
ITransport? destinationTransport = null,
CancellationToken cancellationToken = default)
{
return _servicesScopePerRequest ?
InvokeScopedAsync(handler, args, cancellationToken) :
handler(new(new DestinationBoundMcpServer(this, destinationTransport)) { Params = args }, cancellationToken);
if (_servicesScopePerRequest)
return await InvokeScopedAsync(handler, args, cancellationToken);

InvokeLogHandler(method, McpStatus.RequestReceived, args);

var result = await handler(new(new DestinationBoundMcpServer(this, destinationTransport)) { Params = args },
cancellationToken);

InvokeLogHandler(method, McpStatus.ResponseSent, result);

return result;

async ValueTask<TResult> InvokeScopedAsync(
Func<RequestContext<TParams>, CancellationToken, ValueTask<TResult>> handler,
TParams? args,
CancellationToken cancellationToken)
{
var scope = Services?.GetService<IServiceScopeFactory>()?.CreateAsyncScope();

InvokeLogHandler(method, McpStatus.RequestReceived, args);

try
{
return await handler(
var result = await handler(
new RequestContext<TParams>(new DestinationBoundMcpServer(this, destinationTransport))
{
Services = scope?.ServiceProvider ?? Services,
Params = args
},
cancellationToken).ConfigureAwait(false);

InvokeLogHandler(method, McpStatus.ResponseSent, result);

return result;
}
finally
{
Expand All @@ -535,9 +578,18 @@ private void SetHandler<TRequest, TResponse>(
{
RequestHandlers.Set(method,
(request, destinationTransport, cancellationToken) =>
InvokeHandlerAsync(handler, request, destinationTransport, cancellationToken),
InvokeHandlerAsync(method, handler, request, destinationTransport, cancellationToken),
requestTypeInfo, responseTypeInfo);
}

private void InvokeLogHandler<TParams>(string method, McpStatus status, TParams? args) =>
ServerOptions.LogHandler?.Invoke(new()
{
ServiceProvider = Services,
Json = JsonSerializer.Serialize(args, McpJsonUtilities.DefaultOptions.GetTypeInfo<TParams?>()),
Status = status,
Method = method
});

private void UpdateEndpointNameWithClientInfo()
{
Expand Down
6 changes: 6 additions & 0 deletions src/ModelContextProtocol/Server/McpServerOptions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using ModelContextProtocol.Logging;
using ModelContextProtocol.Protocol;

namespace ModelContextProtocol.Server;
Expand Down Expand Up @@ -76,4 +77,9 @@ public class McpServerOptions
/// </para>
/// </remarks>
public Implementation? KnownClientInfo { get; set; }

/// <summary>
/// Gets or sets a log handler that gets notified when log messages are emitted at the request/response flow.
/// </summary>
public McpLogHandler? LogHandler { get; set; }
}
24 changes: 24 additions & 0 deletions tests/ModelContextProtocol.Tests/Server/McpServerPromptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Moq;
using System.ComponentModel;
using System.Reflection;
using ModelContextProtocol.Logging;

namespace ModelContextProtocol.Tests.Server;

Expand Down Expand Up @@ -168,6 +169,29 @@ public async Task CanReturnText()
Assert.Equal("text", actual.Messages[0].Content.Type);
Assert.Equal(expected, actual.Messages[0].Content.Text);
}

[Fact]
public async Task CanHandleLogging()
{
Mock<IMcpServer> mockServer = new();

var options = new McpServerOptions()
{
LogHandler =
(context) => Assert.Equal(McpStatus.ErrorOccurred, context.Status)
};

mockServer.SetupGet(s => s.ServerOptions).Returns(options);

McpServerPrompt prompt = McpServerPrompt.Create(() =>
{
throw new InvalidOperationException("Test exception");
});

await Assert.ThrowsAsync<InvalidOperationException>(async () => await prompt.GetAsync(
new RequestContext<GetPromptRequestParams>(mockServer.Object),
TestContext.Current.CancellationToken));
}

[Fact]
public async Task CanReturnPromptMessage()
Expand Down
24 changes: 24 additions & 0 deletions tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Moq;
using System.Reflection;
using System.Text.Json.Serialization;
using ModelContextProtocol.Logging;

namespace ModelContextProtocol.Tests.Server;

Expand Down Expand Up @@ -255,6 +256,29 @@ public async Task UriTemplate_CreatedFromParameters_LotsOfTypesSupported()
Assert.Equal("123", ((TextResourceContents)result.Contents[0]).Text);
}

[Fact]
public async Task CanHandleLogging()
{
const string Name = "Hello";
Mock<IMcpServer> server = new();

server.SetupGet(s => s.ServerOptions).Returns(() => new McpServerOptions
{
LogHandler = (context) => Assert.Equal(McpStatus.ErrorOccurred, context.Status)
});

McpServerResource t = McpServerResource.Create(
() => { throw new InvalidOperationException("Test exception"); },
new() { Name = Name });

await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await t.ReadAsync(new RequestContext<ReadResourceRequestParams>(server.Object)
{
Params = new() { Uri = $"resource://{Name}" }
},
TestContext.Current.CancellationToken));
}

[Theory]
[InlineData("resource://Hello?arg1=42&arg2=84")]
[InlineData("resource://Hello?arg1=42&arg2=84&arg3=123")]
Expand Down
Loading