diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs index c55c76465..d522a2f59 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs @@ -212,9 +212,9 @@ public override async ValueTask GetAsync( object? result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false); - return result switch + GetPromptResult getPromptResult = result switch { - GetPromptResult getPromptResult => getPromptResult, + GetPromptResult existingResult => existingResult, string text => new() { @@ -250,5 +250,10 @@ public override async ValueTask GetAsync( _ => throw new InvalidOperationException($"Unknown result type '{result.GetType()}' returned from prompt function."), }; + + // Propagate metadata from the prompt to the result if the result doesn't already have metadata. + getPromptResult.Meta ??= ProtocolPrompt.Meta; + + return getPromptResult; } } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs index fcd855de9..0f9a3006c 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs @@ -375,9 +375,9 @@ public override async ValueTask ReadAsync( object? result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false); // And process the result. - return result switch + ReadResourceResult readResourceResult = result switch { - ReadResourceResult readResourceResult => readResourceResult, + ReadResourceResult existingResult => existingResult, ResourceContents content => new() { @@ -441,5 +441,10 @@ public override async ValueTask ReadAsync( _ => throw new InvalidOperationException($"Unsupported result type '{result.GetType()}' returned from resource function."), }; + + // Propagate metadata from the resource template to the result if the result doesn't already have metadata. + readResourceResult.Meta ??= ProtocolResourceTemplate.Meta; + + return readResourceResult; } } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index f72e5a483..f9616a0ba 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -268,7 +268,7 @@ public override async ValueTask InvokeAsync( result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false); JsonNode? structuredContent = CreateStructuredResponse(result); - return result switch + CallToolResult callToolResult = result switch { AIContent aiContent => new() { @@ -303,7 +303,7 @@ public override async ValueTask InvokeAsync( StructuredContent = structuredContent, }, - CallToolResult callToolResponse => callToolResponse, + CallToolResult existingResult => existingResult, _ => new() { @@ -311,6 +311,11 @@ public override async ValueTask InvokeAsync( StructuredContent = structuredContent, }, }; + + // Propagate metadata from the tool to the result if the result doesn't already have metadata. + callToolResult.Meta ??= ProtocolTool.Meta; + + return callToolResult; } /// Creates a name to use based on the supplied method and naming policy. diff --git a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs index b23a89287..f82cac114 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Text.Json; using System.Text.Json.Nodes; @@ -1246,6 +1247,220 @@ public void McpMetaAttribute_JsonValueForComplexTypes_SerializedCorrectly() Assert.Equal("123", obj["num"]?.ToString()); } + #region Meta Propagation to Result Tests + + [Fact] + public async Task McpServerTool_InvokeAsync_PropagatesMetaToResult() + { + var method = typeof(TestToolMetaPropagationClass).GetMethod(nameof(TestToolMetaPropagationClass.ToolWithMeta))!; + var tool = McpServerTool.Create(method, target: null); + + // Verify tool has meta defined + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal("gpt-4o", tool.ProtocolTool.Meta["model"]?.ToString()); + + var result = await tool.InvokeAsync( + new RequestContext(new Moq.Mock().Object, CreateTestJsonRpcRequest()) { Params = new() { Name = "tool_with_meta" } }, + TestContext.Current.CancellationToken); + + // Verify meta is propagated to result + Assert.NotNull(result.Meta); + Assert.Equal("gpt-4o", result.Meta["model"]?.ToString()); + Assert.Equal("1.0", result.Meta["version"]?.ToString()); + } + + [Fact] + public async Task McpServerTool_InvokeAsync_DoesNotOverrideUserProvidedMeta() + { + var method = typeof(TestToolCallToolResultClass).GetMethod(nameof(TestToolCallToolResultClass.ToolReturnsCallToolResultWithMeta))!; + var tool = McpServerTool.Create(method, target: null); + + // Verify tool has meta defined + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal("tool-meta-value", tool.ProtocolTool.Meta["toolMeta"]?.ToString()); + + var result = await tool.InvokeAsync( + new RequestContext(new Moq.Mock().Object, CreateTestJsonRpcRequest()) { Params = new() { Name = "tool" } }, + TestContext.Current.CancellationToken); + + // Verify that user-provided meta is preserved (not overwritten by tool meta) + Assert.NotNull(result.Meta); + Assert.Equal("user-provided-meta-value", result.Meta["customMeta"]?.ToString()); + Assert.False(result.Meta.ContainsKey("toolMeta")); + } + + [Fact] + public async Task McpServerPrompt_GetAsync_PropagatesMetaToResult() + { + var method = typeof(TestPromptMetaPropagationClass).GetMethod(nameof(TestPromptMetaPropagationClass.PromptWithMeta))!; + var prompt = McpServerPrompt.Create(method, target: null); + + // Verify prompt has meta defined + Assert.NotNull(prompt.ProtocolPrompt.Meta); + Assert.Equal("reasoning", prompt.ProtocolPrompt.Meta["type"]?.ToString()); + + var result = await prompt.GetAsync( + new RequestContext(new Moq.Mock().Object, CreateTestJsonRpcRequest()) { Params = new() { Name = "prompt" } }, + TestContext.Current.CancellationToken); + + // Verify meta is propagated to result + Assert.NotNull(result.Meta); + Assert.Equal("reasoning", result.Meta["type"]?.ToString()); + Assert.Equal("claude-3", result.Meta["model"]?.ToString()); + } + + [Fact] + public async Task McpServerPrompt_GetAsync_DoesNotOverrideUserProvidedMeta() + { + var method = typeof(TestPromptGetPromptResultClass).GetMethod(nameof(TestPromptGetPromptResultClass.PromptReturnsGetPromptResultWithMeta))!; + var prompt = McpServerPrompt.Create(method, target: null); + + // Verify prompt has meta defined + Assert.NotNull(prompt.ProtocolPrompt.Meta); + Assert.Equal("prompt-meta-value", prompt.ProtocolPrompt.Meta["promptMeta"]?.ToString()); + + var result = await prompt.GetAsync( + new RequestContext(new Moq.Mock().Object, CreateTestJsonRpcRequest()) { Params = new() { Name = "prompt" } }, + TestContext.Current.CancellationToken); + + // Verify that user-provided meta is preserved (not overwritten by prompt meta) + Assert.NotNull(result.Meta); + Assert.Equal("user-provided-meta-value", result.Meta["customMeta"]?.ToString()); + Assert.False(result.Meta.ContainsKey("promptMeta")); + } + + [Fact] + public async Task McpServerResource_ReadAsync_PropagatesMetaToResult() + { + var method = typeof(TestResourceClass).GetMethod(nameof(TestResourceClass.ResourceWithMeta))!; + var resource = McpServerResource.Create(method, target: null); + + // Verify resource has meta defined + Assert.NotNull(resource.ProtocolResourceTemplate?.Meta); + Assert.Equal("text/plain", resource.ProtocolResourceTemplate.Meta["encoding"]?.ToString()); + + var result = await resource.ReadAsync( + new RequestContext(new Moq.Mock().Object, CreateTestJsonRpcRequest()) { Params = new() { Uri = "resource://test/123" } }, + TestContext.Current.CancellationToken); + + // Verify meta is propagated to result + Assert.NotNull(result.Meta); + Assert.Equal("text/plain", result.Meta["encoding"]?.ToString()); + Assert.Equal("cached", result.Meta["caching"]?.ToString()); + } + + [Fact] + public async Task McpServerResource_ReadAsync_DoesNotOverrideUserProvidedMeta() + { + var method = typeof(TestResourceReadResourceResultClass).GetMethod(nameof(TestResourceReadResourceResultClass.ResourceReturnsReadResourceResultWithMeta))!; + var resource = McpServerResource.Create(method, target: null); + + // Verify resource has meta defined + Assert.NotNull(resource.ProtocolResourceTemplate?.Meta); + Assert.Equal("resource-meta-value", resource.ProtocolResourceTemplate.Meta["resourceMeta"]?.ToString()); + + var result = await resource.ReadAsync( + new RequestContext(new Moq.Mock().Object, CreateTestJsonRpcRequest()) { Params = new() { Uri = "resource://test/123" } }, + TestContext.Current.CancellationToken); + + // Verify that user-provided meta is preserved (not overwritten by resource meta) + Assert.NotNull(result.Meta); + Assert.Equal("user-provided-meta-value", result.Meta["customMeta"]?.ToString()); + Assert.False(result.Meta.ContainsKey("resourceMeta")); + } + + [Fact] + public async Task McpServerTool_InvokeAsync_WithNoMeta_ResultHasNoMeta() + { + var method = typeof(TestToolMetaPropagationClass).GetMethod(nameof(TestToolMetaPropagationClass.ToolWithoutMeta))!; + var tool = McpServerTool.Create(method, target: null); + + // Verify tool has no meta defined + Assert.Null(tool.ProtocolTool.Meta); + + var result = await tool.InvokeAsync( + new RequestContext(new Moq.Mock().Object, CreateTestJsonRpcRequest()) { Params = new() { Name = "tool_without_meta" } }, + TestContext.Current.CancellationToken); + + // Verify result has no meta + Assert.Null(result.Meta); + } + + [Fact] + public async Task McpServerPrompt_GetAsync_WithNoMeta_ResultHasNoMeta() + { + var method = typeof(TestPromptMetaPropagationClass).GetMethod(nameof(TestPromptMetaPropagationClass.PromptWithoutMeta))!; + var prompt = McpServerPrompt.Create(method, target: null); + + // Verify prompt has no meta defined + Assert.Null(prompt.ProtocolPrompt.Meta); + + var result = await prompt.GetAsync( + new RequestContext(new Moq.Mock().Object, CreateTestJsonRpcRequest()) { Params = new() { Name = "prompt_without_meta" } }, + TestContext.Current.CancellationToken); + + // Verify result has no meta + Assert.Null(result.Meta); + } + + [Fact] + public async Task McpServerResource_ReadAsync_WithNoMeta_ResultHasNoMeta() + { + var method = typeof(TestResourceMetaPropagationClass).GetMethod(nameof(TestResourceMetaPropagationClass.ResourceWithoutMeta))!; + var resource = McpServerResource.Create(method, target: null); + + // Verify resource has no meta defined + Assert.Null(resource.ProtocolResourceTemplate?.Meta); + + var result = await resource.ReadAsync( + new RequestContext(new Moq.Mock().Object, CreateTestJsonRpcRequest()) { Params = new() { Uri = "resource://no-meta/123" } }, + TestContext.Current.CancellationToken); + + // Verify result has no meta + Assert.Null(result.Meta); + } + + private static JsonRpcRequest CreateTestJsonRpcRequest() + { + return new JsonRpcRequest + { + Id = new RequestId("test-id"), + Method = "test/method", + Params = null + }; + } + + // Test classes specifically for result propagation tests (no parameters) + private class TestToolMetaPropagationClass + { + [McpServerTool] + [McpMeta("model", "gpt-4o")] + [McpMeta("version", "1.0")] + public static string ToolWithMeta() => "result"; + + [McpServerTool] + public static string ToolWithoutMeta() => "result"; + } + + private class TestPromptMetaPropagationClass + { + [McpServerPrompt] + [McpMeta("type", "reasoning")] + [McpMeta("model", "claude-3")] + public static string PromptWithMeta() => "result"; + + [McpServerPrompt] + public static string PromptWithoutMeta() => "result"; + } + + private class TestResourceMetaPropagationClass + { + [McpServerResource(UriTemplate = "resource://no-meta/{id}")] + public static string ResourceWithoutMeta(string id) => id; + } + + #endregion + private class TestToolClass { [McpServerTool] @@ -1535,4 +1750,47 @@ private class TestResourceJsonValueMetaClass [McpMeta("permissions", JsonValue = """["read", "write"]""")] public static string ResourceWithJsonValueMeta(string id) => id; } + + // Test classes for user-provided result types with their own meta + private class TestToolCallToolResultClass + { + [McpServerTool] + [McpMeta("toolMeta", "tool-meta-value")] + public static CallToolResult ToolReturnsCallToolResultWithMeta() + { + return new CallToolResult + { + Content = [new TextContentBlock { Text = "test" }], + Meta = new JsonObject { ["customMeta"] = "user-provided-meta-value" } + }; + } + } + + private class TestPromptGetPromptResultClass + { + [McpServerPrompt] + [McpMeta("promptMeta", "prompt-meta-value")] + public static GetPromptResult PromptReturnsGetPromptResultWithMeta() + { + return new GetPromptResult + { + Messages = [new PromptMessage { Role = Role.User, Content = new TextContentBlock { Text = "test" } }], + Meta = new JsonObject { ["customMeta"] = "user-provided-meta-value" } + }; + } + } + + private class TestResourceReadResourceResultClass + { + [McpServerResource(UriTemplate = "resource://test/{id}")] + [McpMeta("resourceMeta", "resource-meta-value")] + public static ReadResourceResult ResourceReturnsReadResourceResultWithMeta(string id) + { + return new ReadResourceResult + { + Contents = [new TextResourceContents { Uri = $"resource://test/{id}", Text = id }], + Meta = new JsonObject { ["customMeta"] = "user-provided-meta-value" } + }; + } + } }