From a28e2a8517c47e6e2839dc2b683258e1d95d2efa Mon Sep 17 00:00:00 2001 From: Carl de Billy's personal bot - Autocarl Date: Thu, 25 Jun 2026 20:11:38 -0400 Subject: [PATCH 1/6] feat: allow MCP resources to declare MIME types --- src/Repl.Core/CommandBuilder.cs | 18 +++++ .../Documentation/DocumentationEngine.cs | 6 +- src/Repl.Core/Documentation/ReplDocCommand.cs | 1 + .../Documentation/ReplDocResource.cs | 9 ++- src/Repl.Core/IOutputTransformer.cs | 5 ++ .../Output/HumanOutputTransformer.cs | 2 + src/Repl.Core/Output/JsonOutputTransformer.cs | 2 + .../Output/MarkdownOutputTransformer.cs | 2 + src/Repl.Core/Output/XmlOutputTransformer.cs | 2 + src/Repl.Core/Output/YamlOutputTransformer.cs | 2 + src/Repl.Mcp/McpServerHandler.cs | 22 +++++- src/Repl.Mcp/McpToolAdapter.cs | 4 +- src/Repl.Mcp/ReplMcpServerResource.cs | 13 +++- .../Given_McpResourceParameters.cs | 77 ++++++++++++++++++- .../SpectreHumanOutputTransformer.cs | 3 + ...Given_SpectreOutputTransformerMimeTypes.cs | 14 ++++ .../Given_CommandBuilderEnrichment.cs | 61 +++++++++++++++ .../Given_OutputTransformerMimeTypes.cs | 35 +++++++++ 18 files changed, 269 insertions(+), 9 deletions(-) create mode 100644 src/Repl.SpectreTests/Given_SpectreOutputTransformerMimeTypes.cs create mode 100644 src/Repl.Tests/Given_OutputTransformerMimeTypes.cs diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index f62d369..947882f 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -81,6 +81,11 @@ internal CommandBuilder(string route, Delegate handler) /// public bool IsResource { get; private set; } + /// + /// Gets the MIME type to advertise when this command is exposed as an MCP resource. + /// + public string? ResourceMimeType { get; private set; } + /// /// Gets a value indicating whether this command is a prompt source. /// @@ -339,6 +344,19 @@ public CommandBuilder AsResource() return this; } + /// + /// Marks this command as a resource and declares the MIME type advertised through MCP. + /// + /// MIME type to expose in MCP resource metadata and read results. + /// The same builder instance. + public CommandBuilder AsResource(string mimeType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(mimeType); + IsResource = true; + ResourceMimeType = mimeType.Trim(); + return this; + } + /// /// Marks this command as a prompt source. /// The handler return value becomes the prompt message template. diff --git a/src/Repl.Core/Documentation/DocumentationEngine.cs b/src/Repl.Core/Documentation/DocumentationEngine.cs index 324b795..0c95363 100644 --- a/src/Repl.Core/Documentation/DocumentationEngine.cs +++ b/src/Repl.Core/Documentation/DocumentationEngine.cs @@ -81,7 +81,10 @@ public object CreateDocumentationModelInternal(string? targetPath) Description: cmd.Description, Details: cmd.Details, Arguments: cmd.Arguments, - Options: cmd.Options)) + Options: cmd.Options) + { + MimeType = cmd.ResourceMimeType, + }) .ToArray(); var model = new ReplDocumentationModel( App: BuildDocumentationApp(), @@ -198,6 +201,7 @@ private ReplDocCommand BuildDocumentationCommand(RouteDefinition route) Metadata: route.Command.Metadata.Count > 0 ? route.Command.Metadata : null, Answers: answers.Length > 0 ? answers : null, IsResource: route.Command.IsResource, + ResourceMimeType: route.Command.ResourceMimeType, IsPrompt: route.Command.IsPrompt, AcceptsPagingInput: acceptsPagingInput, EmitsPagedResult: emitsPagedResult); diff --git a/src/Repl.Core/Documentation/ReplDocCommand.cs b/src/Repl.Core/Documentation/ReplDocCommand.cs index a9556ce..6adb02d 100644 --- a/src/Repl.Core/Documentation/ReplDocCommand.cs +++ b/src/Repl.Core/Documentation/ReplDocCommand.cs @@ -15,6 +15,7 @@ public sealed record ReplDocCommand( IReadOnlyDictionary? Metadata = null, IReadOnlyList? Answers = null, bool IsResource = false, + string? ResourceMimeType = null, bool IsPrompt = false, bool AcceptsPagingInput = false, bool EmitsPagedResult = false); diff --git a/src/Repl.Core/Documentation/ReplDocResource.cs b/src/Repl.Core/Documentation/ReplDocResource.cs index 33a3049..997ec70 100644 --- a/src/Repl.Core/Documentation/ReplDocResource.cs +++ b/src/Repl.Core/Documentation/ReplDocResource.cs @@ -8,4 +8,11 @@ public sealed record ReplDocResource( string? Description, string? Details, IReadOnlyList Arguments, - IReadOnlyList Options); + IReadOnlyList Options) +{ + /// + /// Gets the explicit MIME type override to advertise when this resource is exposed through MCP. + /// When null, MCP consumers should use the active output transformer's MIME type. + /// + public string? MimeType { get; init; } +} diff --git a/src/Repl.Core/IOutputTransformer.cs b/src/Repl.Core/IOutputTransformer.cs index ae91438..c4b8a18 100644 --- a/src/Repl.Core/IOutputTransformer.cs +++ b/src/Repl.Core/IOutputTransformer.cs @@ -10,6 +10,11 @@ public interface IOutputTransformer /// string Name { get; } + /// + /// Gets the MIME type produced by this transformer. + /// + string MimeType => "text/plain"; + /// /// Gets a value indicating whether this transformer can be displayed by the interactive result pager. /// diff --git a/src/Repl.Core/Output/HumanOutputTransformer.cs b/src/Repl.Core/Output/HumanOutputTransformer.cs index 80f5662..6334010 100644 --- a/src/Repl.Core/Output/HumanOutputTransformer.cs +++ b/src/Repl.Core/Output/HumanOutputTransformer.cs @@ -23,6 +23,8 @@ public HumanOutputTransformer(Func resolveRenderSettings) public string Name => "human"; + public string MimeType => "text/plain"; + public bool SupportsInteractivePaging => true; public ValueTask TransformAsync(object? value, CancellationToken cancellationToken = default) diff --git a/src/Repl.Core/Output/JsonOutputTransformer.cs b/src/Repl.Core/Output/JsonOutputTransformer.cs index e1ea52b..da44de7 100644 --- a/src/Repl.Core/Output/JsonOutputTransformer.cs +++ b/src/Repl.Core/Output/JsonOutputTransformer.cs @@ -7,6 +7,8 @@ internal sealed class JsonOutputTransformer(JsonSerializerOptions serializerOpti { public string Name => "json"; + public string MimeType => "application/json"; + public ValueTask TransformAsync(object? value, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Repl.Core/Output/MarkdownOutputTransformer.cs b/src/Repl.Core/Output/MarkdownOutputTransformer.cs index c1f3119..bdba66e 100644 --- a/src/Repl.Core/Output/MarkdownOutputTransformer.cs +++ b/src/Repl.Core/Output/MarkdownOutputTransformer.cs @@ -13,6 +13,8 @@ internal sealed class MarkdownOutputTransformer : IOutputTransformer public string Name => "markdown"; + public string MimeType => "text/markdown"; + public ValueTask TransformAsync(object? value, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Repl.Core/Output/XmlOutputTransformer.cs b/src/Repl.Core/Output/XmlOutputTransformer.cs index b57c38d..66d358a 100644 --- a/src/Repl.Core/Output/XmlOutputTransformer.cs +++ b/src/Repl.Core/Output/XmlOutputTransformer.cs @@ -9,6 +9,8 @@ internal sealed class XmlOutputTransformer(JsonSerializerOptions serializerOptio { public string Name => "xml"; + public string MimeType => "application/xml"; + public ValueTask TransformAsync(object? value, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Repl.Core/Output/YamlOutputTransformer.cs b/src/Repl.Core/Output/YamlOutputTransformer.cs index 142ce69..972cf68 100644 --- a/src/Repl.Core/Output/YamlOutputTransformer.cs +++ b/src/Repl.Core/Output/YamlOutputTransformer.cs @@ -7,6 +7,8 @@ internal sealed class YamlOutputTransformer(JsonSerializerOptions serializerOpti { public string Name => "yaml"; + public string MimeType => "application/yaml"; + public ValueTask TransformAsync(object? value, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index aff21ab..cb8ea3e 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -822,6 +822,7 @@ private List GenerateResources( Dictionary commandsByPath) { var resources = new List(); + var defaultResourceMimeType = ResolveForcedOutputMimeType(); foreach (var resource in model.Resources) { @@ -854,7 +855,12 @@ private List GenerateResources( } var uriTemplate = McpToolNameFlattener.BuildResourceUri(resource.Path, _options.ResourceUriScheme); - var mcpResource = new ReplMcpServerResource(resource, resourceName, uriTemplate, adapter); + var mcpResource = new ReplMcpServerResource( + resource, + resourceName, + uriTemplate, + adapter, + defaultResourceMimeType); if (docCommand is not null) { @@ -872,6 +878,20 @@ private List GenerateResources( return resources; } + private string ResolveForcedOutputMimeType() + { + if (_app is CoreReplApp coreApp + && coreApp.OptionsSnapshot.Output.Transformers.TryGetValue( + McpToolAdapter.ForcedOutputFormat, + out var transformer) + && !string.IsNullOrWhiteSpace(transformer.MimeType)) + { + return transformer.MimeType; + } + + return "text/plain"; + } + private static bool TryGetAppResourceOptions( ReplDocCommand? command, [NotNullWhen(true)] out McpAppCommandResourceOptions? options) diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 132153f..112d0f4 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -14,6 +14,8 @@ namespace Repl.Mcp; /// internal sealed partial class McpToolAdapter { + internal const string ForcedOutputFormat = "json"; + private readonly ICoreReplApp _app; private readonly ReplMcpServerOptions _options; private readonly IServiceProvider _services; @@ -125,7 +127,7 @@ private async Task ExecuteThroughPipelineAsync( ?.PushProgressToken(progressToken); // Force JSON output — agents consume structured data, not human tables/banners. - var effectiveTokens = new List(tokens.Count + 1) { "--output:json" }; + var effectiveTokens = new List(tokens.Count + 1) { $"--output:{ForcedOutputFormat}" }; effectiveTokens.AddRange(tokens); using (ReplSessionIO.SetSession( diff --git a/src/Repl.Mcp/ReplMcpServerResource.cs b/src/Repl.Mcp/ReplMcpServerResource.cs index 5421769..3375eb6 100644 --- a/src/Repl.Mcp/ReplMcpServerResource.cs +++ b/src/Repl.Mcp/ReplMcpServerResource.cs @@ -13,7 +13,10 @@ namespace Repl.Mcp; /// internal sealed partial class ReplMcpServerResource : McpServerResource { + private const string DefaultMimeType = "text/plain"; + private readonly string _resourceName; + private readonly string _mimeType; private readonly McpToolAdapter _adapter; private readonly ResourceTemplate _protocolResourceTemplate; private readonly Regex? _uriParser; @@ -23,16 +26,20 @@ public ReplMcpServerResource( ReplDocResource resource, string resourceName, string uriTemplate, - McpToolAdapter adapter) + McpToolAdapter adapter, + string? defaultMimeType = null) { _resourceName = resourceName; + _mimeType = !string.IsNullOrWhiteSpace(resource.MimeType) + ? resource.MimeType + : string.IsNullOrWhiteSpace(defaultMimeType) ? DefaultMimeType : defaultMimeType; _adapter = adapter; _protocolResourceTemplate = new ResourceTemplate { Name = resourceName, Description = resource.Description, UriTemplate = uriTemplate, - MimeType = "text/plain", + MimeType = _mimeType, }; // Build a regex to extract template variables from the URI. @@ -90,7 +97,7 @@ public override async ValueTask ReadAsync( new TextResourceContents { Uri = request.Params.Uri, - MimeType = "text/plain", + MimeType = _mimeType, Text = text, }, ], diff --git a/src/Repl.McpTests/Given_McpResourceParameters.cs b/src/Repl.McpTests/Given_McpResourceParameters.cs index c557692..432f73f 100644 --- a/src/Repl.McpTests/Given_McpResourceParameters.cs +++ b/src/Repl.McpTests/Given_McpResourceParameters.cs @@ -69,9 +69,82 @@ public async Task When_ParameterlessResourceRead_Then_ReturnsOutput() await using (session.ConfigureAwait(false)) { var result = await session.Client.ReadResourceAsync("repl://status").ConfigureAwait(false); + var content = result.Contents.OfType().First(); - var text = result.Contents.OfType().First().Text; - text.Should().Contain("all-ok"); + content.Text.Should().Contain("all-ok"); + content.MimeType.Should().Be("application/json"); + } + } + + [TestMethod] + [Description("Undeclared resource MIME type defaults to the forced MCP output converter MIME type.")] + public async Task When_ResourceHasNoDeclaredMimeType_Then_ListAndReadUseForcedJsonMimeType() + { + var session = await McpTestFixture.CreateAsync( + app => app.Map("ops status", () => new + { + Service = "checkout", + Healthy = true, + }) + .ReadOnly() + .AsResource()).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + var resources = await session.Client.ListResourcesAsync().ConfigureAwait(false); + resources.Should().ContainSingle(r => string.Equals(r.Uri, "repl://ops/status", StringComparison.Ordinal)).Which + .MimeType.Should().Be("application/json"); + + var result = await session.Client.ReadResourceAsync("repl://ops/status").ConfigureAwait(false); + var content = result.Contents.OfType().Single(); + + content.MimeType.Should().Be("application/json"); + content.Text.Should().Contain("checkout"); + } + } + + [TestMethod] + [Description("Explicit resource MIME type override wins over the forced MCP output converter MIME type.")] + public async Task When_ResourceHasExplicitMimeTypeOverride_Then_ListAndReadUseOverride() + { + var session = await McpTestFixture.CreateAsync( + app => app.Map("ops status", () => new + { + Service = "checkout", + Healthy = true, + }) + .ReadOnly() + .AsResource(mimeType: "application/vnd.repl.status+json")).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + var resources = await session.Client.ListResourcesAsync().ConfigureAwait(false); + resources.Should().ContainSingle(r => string.Equals(r.Uri, "repl://ops/status", StringComparison.Ordinal)).Which + .MimeType.Should().Be("application/vnd.repl.status+json"); + + var result = await session.Client.ReadResourceAsync("repl://ops/status").ConfigureAwait(false); + var content = result.Contents.OfType().Single(); + + content.MimeType.Should().Be("application/vnd.repl.status+json"); + content.Text.Should().Contain("checkout"); + } + } + + [TestMethod] + [Description("Undeclared resource templates use the forced MCP output converter MIME type.")] + public async Task When_TemplatedResourceHasNoDeclaredMimeType_Then_TemplateUsesForcedJsonMimeType() + { + var session = await McpTestFixture.CreateAsync( + app => app.Map("docs {name}", (string name) => $"# {name}") + .ReadOnly() + .AsResource()).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + var templates = await session.Client.ListResourceTemplatesAsync().ConfigureAwait(false); + + templates.Should().ContainSingle(t => t.UriTemplate.Contains("{name}", StringComparison.Ordinal)).Which + .MimeType.Should().Be("application/json"); } } diff --git a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs index 7742a0c..2472619 100644 --- a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs +++ b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs @@ -29,6 +29,9 @@ public SpectreHumanOutputTransformer(Func resolveRenderSett /// public string Name => "spectre"; + /// + public string MimeType => "text/plain"; + /// public bool SupportsInteractivePaging => true; diff --git a/src/Repl.SpectreTests/Given_SpectreOutputTransformerMimeTypes.cs b/src/Repl.SpectreTests/Given_SpectreOutputTransformerMimeTypes.cs new file mode 100644 index 0000000..d61f0cb --- /dev/null +++ b/src/Repl.SpectreTests/Given_SpectreOutputTransformerMimeTypes.cs @@ -0,0 +1,14 @@ +namespace Repl.SpectreTests; + +[TestClass] +public sealed class Given_SpectreOutputTransformerMimeTypes +{ + [TestMethod] + [Description("Verifies the Spectre human transformer advertises text/plain by default.")] + public void When_SpectreTransformerIsCreated_Then_MimeTypeIsTextPlain() + { + var transformer = new SpectreHumanOutputTransformer(); + + transformer.MimeType.Should().Be("text/plain"); + } +} diff --git a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs index 5d03633..a60e629 100644 --- a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs +++ b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs @@ -103,6 +103,32 @@ public void When_AsResourceIsCalled_Then_IsResourceIsTrue() command.IsResource.Should().BeTrue(); } + [TestMethod] + [Description("Verifies AsResource can declare an explicit MCP resource MIME type override.")] + public void When_AsResourceIsCalledWithMimeType_Then_ResourceMimeTypeIsStored() + { + var sut = CoreReplApp.Create(); + var command = sut.Map("contacts", () => "ok"); + + var chained = command.AsResource(mimeType: "application/vnd.repl.contacts+json"); + + chained.Should().BeSameAs(command); + command.IsResource.Should().BeTrue(); + command.ResourceMimeType.Should().Be("application/vnd.repl.contacts+json"); + } + + [TestMethod] + [Description("Verifies AsResource rejects empty MIME types.")] + public void When_AsResourceIsCalledWithEmptyMimeType_Then_Throws() + { + var sut = CoreReplApp.Create(); + var command = sut.Map("contacts", () => "ok"); + + var act = () => command.AsResource(mimeType: " "); + + act.Should().Throw(); + } + [TestMethod] [Description("Verifies AsPrompt marks the command as a prompt source.")] public void When_AsPromptIsCalled_Then_IsPromptIsTrue() @@ -163,10 +189,45 @@ public void When_DocumentationModelIsCreated_Then_EnrichedFieldsArePresent() cmd.Annotations.Should().NotBeNull(); cmd.Annotations!.ReadOnly.Should().BeTrue(); cmd.IsResource.Should().BeTrue(); + cmd.ResourceMimeType.Should().BeNull(); cmd.Metadata.Should().NotBeNull(); cmd.Metadata!["scope"].Should().Be("crm"); } + [TestMethod] + [Description("Verifies explicit resource MIME type override propagates through the documentation model.")] + public void When_ResourceMimeTypeIsConfigured_Then_DocumentationModelContainsIt() + { + var sut = CoreReplApp.Create(); + sut.Map("contacts", () => "ok") + .WithDescription("List contacts") + .AsResource(mimeType: "application/vnd.repl.contacts+json"); + + var model = sut.CreateDocumentationModel(); + + model.Commands.Should().ContainSingle(c => c.Path == "contacts").Which + .ResourceMimeType.Should().Be("application/vnd.repl.contacts+json"); + model.Resources.Should().ContainSingle(r => r.Path == "contacts").Which + .MimeType.Should().Be("application/vnd.repl.contacts+json"); + } + + [TestMethod] + [Description("Verifies an undeclared resource MIME type stays null in documentation so consumers can use their active converter.")] + public void When_ResourceMimeTypeIsNotConfigured_Then_DocumentationResourceMimeTypeIsNull() + { + var sut = CoreReplApp.Create(); + sut.Map("contacts", () => "ok") + .WithDescription("List contacts") + .AsResource(); + + var model = sut.CreateDocumentationModel(); + + model.Commands.Should().ContainSingle(c => c.Path == "contacts").Which + .ResourceMimeType.Should().BeNull(); + model.Resources.Should().ContainSingle(r => r.Path == "contacts").Which + .MimeType.Should().BeNull(); + } + [TestMethod] [Description("Verifies resources collection is populated from AsResource commands.")] public void When_CommandIsMarkedAsResource_Then_ResourcesCollectionContainsIt() diff --git a/src/Repl.Tests/Given_OutputTransformerMimeTypes.cs b/src/Repl.Tests/Given_OutputTransformerMimeTypes.cs new file mode 100644 index 0000000..cc838f3 --- /dev/null +++ b/src/Repl.Tests/Given_OutputTransformerMimeTypes.cs @@ -0,0 +1,35 @@ +namespace Repl.Tests; + +[TestClass] +public sealed class Given_OutputTransformerMimeTypes +{ + [TestMethod] + [Description("Verifies built-in output transformers advertise their produced MIME type.")] + public void When_BuiltInTransformersAreRegistered_Then_MimeTypesAreAdvertised() + { + var options = new OutputOptions(); + + options.Transformers["json"].MimeType.Should().Be("application/json"); + options.Transformers["xml"].MimeType.Should().Be("application/xml"); + options.Transformers["yaml"].MimeType.Should().Be("application/yaml"); + options.Transformers["markdown"].MimeType.Should().Be("text/markdown"); + options.Transformers["human"].MimeType.Should().Be("text/plain"); + } + + [TestMethod] + [Description("Verifies custom output transformers default to text/plain unless they opt in.")] + public void When_CustomTransformerDoesNotDeclareMimeType_Then_DefaultIsTextPlain() + { + IOutputTransformer transformer = new StubTransformer(); + + transformer.MimeType.Should().Be("text/plain"); + } + + private sealed class StubTransformer : IOutputTransformer + { + public string Name => "stub"; + + public ValueTask TransformAsync(object? value, CancellationToken cancellationToken = default) => + ValueTask.FromResult(value?.ToString() ?? string.Empty); + } +} From ede771a97c0d1369f74957099d4e33b0b0b6b7e1 Mon Sep 17 00:00:00 2001 From: Carl de Billy's personal bot - Autocarl Date: Thu, 25 Jun 2026 20:46:39 -0400 Subject: [PATCH 2/6] fix: align MCP resource MIME with converter output --- docs/mcp-reference.md | 8 +++ src/Repl.Core/CommandBuilder.cs | 18 ----- .../Documentation/DocumentationEngine.cs | 6 +- src/Repl.Core/Documentation/ReplDocCommand.cs | 1 - .../Documentation/ReplDocResource.cs | 9 +-- src/Repl.Mcp/McpServerHandler.cs | 18 +---- src/Repl.Mcp/McpToolAdapter.cs | 72 ++++++++++++++++--- src/Repl.Mcp/ReplMcpServerResource.cs | 26 +++---- .../Given_McpResourceParameters.cs | 72 +++++++++++++------ .../Given_CommandBuilderEnrichment.cs | 61 ---------------- 10 files changed, 136 insertions(+), 155 deletions(-) diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index d6f9954..b275dfe 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -39,6 +39,14 @@ app.Map("deploy {env}", handler) | `.AsMcpAppResource()` | Yes (launcher text) | Yes (`ui://` HTML resource) | No | | `.AutomationHidden()` | No | No | No | +### Command-backed resource MIME types + +Standard `.AsResource()` and auto-promoted `.ReadOnly()` resources are rendered through the MCP output converter. The default MCP converter is JSON, so command-backed resources advertise `application/json` and `resources/read` returns serialized JSON text for normal command results. This is a wire-observable change from earlier versions that advertised `text/plain` for the same JSON content. + +Per-resource non-JSON output is not declared on `.AsResource()` today: the media type must come from the output path that actually produced the bytes. Use MCP Apps resources for HTML (`text/html;profile=mcp-app`) or wait for resource-specific converter/blob support before exposing Markdown, YAML, or binary resource bodies. + +Resource reads bypass the tool-call text fallback, so a handler with no value keeps the serialized JSON payload (for example `null`) instead of the tool placeholder text (`OK`). + > **Compatibility fallback:** Only ~39% of clients support resources and ~38% support prompts. Enable `ResourceFallbackToTools` and/or `PromptFallbackToTools` to also expose them as tools: > > ```csharp diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index 947882f..f62d369 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -81,11 +81,6 @@ internal CommandBuilder(string route, Delegate handler) /// public bool IsResource { get; private set; } - /// - /// Gets the MIME type to advertise when this command is exposed as an MCP resource. - /// - public string? ResourceMimeType { get; private set; } - /// /// Gets a value indicating whether this command is a prompt source. /// @@ -344,19 +339,6 @@ public CommandBuilder AsResource() return this; } - /// - /// Marks this command as a resource and declares the MIME type advertised through MCP. - /// - /// MIME type to expose in MCP resource metadata and read results. - /// The same builder instance. - public CommandBuilder AsResource(string mimeType) - { - ArgumentException.ThrowIfNullOrWhiteSpace(mimeType); - IsResource = true; - ResourceMimeType = mimeType.Trim(); - return this; - } - /// /// Marks this command as a prompt source. /// The handler return value becomes the prompt message template. diff --git a/src/Repl.Core/Documentation/DocumentationEngine.cs b/src/Repl.Core/Documentation/DocumentationEngine.cs index 0c95363..324b795 100644 --- a/src/Repl.Core/Documentation/DocumentationEngine.cs +++ b/src/Repl.Core/Documentation/DocumentationEngine.cs @@ -81,10 +81,7 @@ public object CreateDocumentationModelInternal(string? targetPath) Description: cmd.Description, Details: cmd.Details, Arguments: cmd.Arguments, - Options: cmd.Options) - { - MimeType = cmd.ResourceMimeType, - }) + Options: cmd.Options)) .ToArray(); var model = new ReplDocumentationModel( App: BuildDocumentationApp(), @@ -201,7 +198,6 @@ private ReplDocCommand BuildDocumentationCommand(RouteDefinition route) Metadata: route.Command.Metadata.Count > 0 ? route.Command.Metadata : null, Answers: answers.Length > 0 ? answers : null, IsResource: route.Command.IsResource, - ResourceMimeType: route.Command.ResourceMimeType, IsPrompt: route.Command.IsPrompt, AcceptsPagingInput: acceptsPagingInput, EmitsPagedResult: emitsPagedResult); diff --git a/src/Repl.Core/Documentation/ReplDocCommand.cs b/src/Repl.Core/Documentation/ReplDocCommand.cs index 6adb02d..a9556ce 100644 --- a/src/Repl.Core/Documentation/ReplDocCommand.cs +++ b/src/Repl.Core/Documentation/ReplDocCommand.cs @@ -15,7 +15,6 @@ public sealed record ReplDocCommand( IReadOnlyDictionary? Metadata = null, IReadOnlyList? Answers = null, bool IsResource = false, - string? ResourceMimeType = null, bool IsPrompt = false, bool AcceptsPagingInput = false, bool EmitsPagedResult = false); diff --git a/src/Repl.Core/Documentation/ReplDocResource.cs b/src/Repl.Core/Documentation/ReplDocResource.cs index 997ec70..33a3049 100644 --- a/src/Repl.Core/Documentation/ReplDocResource.cs +++ b/src/Repl.Core/Documentation/ReplDocResource.cs @@ -8,11 +8,4 @@ public sealed record ReplDocResource( string? Description, string? Details, IReadOnlyList Arguments, - IReadOnlyList Options) -{ - /// - /// Gets the explicit MIME type override to advertise when this resource is exposed through MCP. - /// When null, MCP consumers should use the active output transformer's MIME type. - /// - public string? MimeType { get; init; } -} + IReadOnlyList Options); diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index cb8ea3e..2ff84eb 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -822,7 +822,7 @@ private List GenerateResources( Dictionary commandsByPath) { var resources = new List(); - var defaultResourceMimeType = ResolveForcedOutputMimeType(); + var resourceMimeType = adapter.ForcedOutputMimeType; foreach (var resource in model.Resources) { @@ -860,7 +860,7 @@ private List GenerateResources( resourceName, uriTemplate, adapter, - defaultResourceMimeType); + resourceMimeType); if (docCommand is not null) { @@ -878,20 +878,6 @@ private List GenerateResources( return resources; } - private string ResolveForcedOutputMimeType() - { - if (_app is CoreReplApp coreApp - && coreApp.OptionsSnapshot.Output.Transformers.TryGetValue( - McpToolAdapter.ForcedOutputFormat, - out var transformer) - && !string.IsNullOrWhiteSpace(transformer.MimeType)) - { - return transformer.MimeType; - } - - return "text/plain"; - } - private static bool TryGetAppResourceOptions( ReplDocCommand? command, [NotNullWhen(true)] out McpAppCommandResourceOptions? options) diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 112d0f4..9a25cb9 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -15,6 +15,7 @@ namespace Repl.Mcp; internal sealed partial class McpToolAdapter { internal const string ForcedOutputFormat = "json"; + private const string TextPlainMimeType = "text/plain"; private readonly ICoreReplApp _app; private readonly ReplMcpServerOptions _options; @@ -29,6 +30,21 @@ public McpToolAdapter(ICoreReplApp app, ReplMcpServerOptions options, IServicePr _services = services; } + internal string ForcedOutputMimeType + { + get + { + if (_app is CoreReplApp coreApp + && coreApp.OptionsSnapshot.Output.Transformers.TryGetValue(ForcedOutputFormat, out var transformer) + && !string.IsNullOrWhiteSpace(transformer.MimeType)) + { + return transformer.MimeType; + } + + return TextPlainMimeType; + } + } + /// /// Clears all registered routes. Called before rebuilding on routing invalidation. /// @@ -98,11 +114,52 @@ public async Task InvokeAsync( } var (tokens, prefills) = PrepareExecution(command, arguments); - return await ExecuteThroughPipelineAsync(tokens, prefills, server, progressToken, ct) + var invocation = await ExecuteThroughPipelineAsync(tokens, prefills, server, progressToken, ct) .ConfigureAwait(false); + var output = invocation.Output; + if (string.IsNullOrWhiteSpace(output)) + { + output = invocation.ExitCode == 0 + ? "OK" + : $"Command failed with exit code {invocation.ExitCode}."; + } + + return BuildToolResult(output, invocation.ExitCode, _options.PagedResultTextMode); } - private async Task ExecuteThroughPipelineAsync( + internal async Task InvokeResourceAsync( + string resourceName, + IDictionary arguments, + McpServer? server, + ProgressToken? progressToken, + CancellationToken ct) + { + if (!_toolRoutes.TryGetValue(resourceName, out var command)) + { + return new McpResourceReadInvocation($"Unknown resource: {resourceName}", TextPlainMimeType, IsError: true); + } + + var (tokens, prefills) = PrepareExecution(command, arguments); + var invocation = await ExecuteThroughPipelineAsync(tokens, prefills, server, progressToken, ct) + .ConfigureAwait(false); + + if (invocation.ExitCode != 0) + { + var error = string.IsNullOrWhiteSpace(invocation.Output) + ? $"Command failed with exit code {invocation.ExitCode}." + : invocation.Output; + return new McpResourceReadInvocation(error, TextPlainMimeType, IsError: true); + } + + if (string.IsNullOrWhiteSpace(invocation.Output)) + { + return new McpResourceReadInvocation("OK", TextPlainMimeType, IsError: false); + } + + return new McpResourceReadInvocation(invocation.Output, ForcedOutputMimeType, IsError: false); + } + + private async Task ExecuteThroughPipelineAsync( List tokens, Dictionary prefills, McpServer? server, @@ -142,15 +199,14 @@ private async Task ExecuteThroughPipelineAsync( effectiveTokens.ToArray(), mcpServices, ct).ConfigureAwait(false); var output = outputWriter.ToString().Trim(); - if (string.IsNullOrWhiteSpace(output)) - { - output = exitCode == 0 ? "OK" : $"Command failed with exit code {exitCode}."; - } - - return BuildToolResult(output, exitCode, _options.PagedResultTextMode); + return new McpPipelineInvocation(output, exitCode); } } + internal readonly record struct McpResourceReadInvocation(string Text, string MimeType, bool IsError); + + private readonly record struct McpPipelineInvocation(string Output, int ExitCode); + private static CallToolResult BuildToolResult(string output, int exitCode, McpPagedResultTextMode pagedTextMode) { if (exitCode == 0 && TryCreatePagedStructuredResult(output, out var structuredContent, out var summary)) diff --git a/src/Repl.Mcp/ReplMcpServerResource.cs b/src/Repl.Mcp/ReplMcpServerResource.cs index 3375eb6..bd97970 100644 --- a/src/Repl.Mcp/ReplMcpServerResource.cs +++ b/src/Repl.Mcp/ReplMcpServerResource.cs @@ -13,8 +13,6 @@ namespace Repl.Mcp; /// internal sealed partial class ReplMcpServerResource : McpServerResource { - private const string DefaultMimeType = "text/plain"; - private readonly string _resourceName; private readonly string _mimeType; private readonly McpToolAdapter _adapter; @@ -27,12 +25,11 @@ public ReplMcpServerResource( string resourceName, string uriTemplate, McpToolAdapter adapter, - string? defaultMimeType = null) + string mimeType) { + ArgumentException.ThrowIfNullOrWhiteSpace(mimeType); _resourceName = resourceName; - _mimeType = !string.IsNullOrWhiteSpace(resource.MimeType) - ? resource.MimeType - : string.IsNullOrWhiteSpace(defaultMimeType) ? DefaultMimeType : defaultMimeType; + _mimeType = mimeType; _adapter = adapter; _protocolResourceTemplate = new ResourceTemplate { @@ -73,23 +70,18 @@ public override async ValueTask ReadAsync( { var arguments = ExtractArguments(request.Params.Uri); - var result = await _adapter.InvokeAsync( + var result = await _adapter.InvokeResourceAsync( _resourceName, arguments, request.Server, progressToken: null, - cancellationToken, - allowStaticResults: false) + cancellationToken) .ConfigureAwait(false); - if (result.IsError == true) + if (result.IsError) { - var errorText = result.Content?.OfType().FirstOrDefault()?.Text - ?? "Resource read failed."; - throw new McpException(errorText); + throw new McpException(result.Text); } - - var text = result.Content?.OfType().FirstOrDefault()?.Text ?? ""; return new ReadResourceResult { Contents = @@ -97,8 +89,8 @@ public override async ValueTask ReadAsync( new TextResourceContents { Uri = request.Params.Uri, - MimeType = _mimeType, - Text = text, + MimeType = result.MimeType, + Text = result.Text, }, ], }; diff --git a/src/Repl.McpTests/Given_McpResourceParameters.cs b/src/Repl.McpTests/Given_McpResourceParameters.cs index 432f73f..b1581c4 100644 --- a/src/Repl.McpTests/Given_McpResourceParameters.cs +++ b/src/Repl.McpTests/Given_McpResourceParameters.cs @@ -48,7 +48,12 @@ public void When_IsMatchCalledWithConcreteUri_Then_ReturnsTrue() { var resource = new ReplDocResource( Path: "config {env}", Description: "desc", Details: null, Arguments: [], Options: []); - var sut = new ReplMcpServerResource(resource, resourceName: "config", uriTemplate: "repl://config/{env}", adapter: null!); + var sut = new ReplMcpServerResource( + resource, + resourceName: "config", + uriTemplate: "repl://config/{env}", + adapter: null!, + mimeType: "application/json"); sut.IsMatch("repl://config/production").Should().BeTrue(); sut.IsMatch("repl://config/staging").Should().BeTrue(); @@ -104,47 +109,72 @@ public async Task When_ResourceHasNoDeclaredMimeType_Then_ListAndReadUseForcedJs } [TestMethod] - [Description("Explicit resource MIME type override wins over the forced MCP output converter MIME type.")] - public async Task When_ResourceHasExplicitMimeTypeOverride_Then_ListAndReadUseOverride() + [Description("Resource templates use the forced MCP output converter MIME type.")] + public async Task When_TemplatedResource_Then_TemplateUsesForcedJsonMimeType() { var session = await McpTestFixture.CreateAsync( - app => app.Map("ops status", () => new - { - Service = "checkout", - Healthy = true, - }) + app => app.Map("docs {name}", (string name) => $"# {name}") .ReadOnly() - .AsResource(mimeType: "application/vnd.repl.status+json")).ConfigureAwait(false); + .AsResource()).ConfigureAwait(false); await using (session.ConfigureAwait(false)) { - var resources = await session.Client.ListResourcesAsync().ConfigureAwait(false); - resources.Should().ContainSingle(r => string.Equals(r.Uri, "repl://ops/status", StringComparison.Ordinal)).Which - .MimeType.Should().Be("application/vnd.repl.status+json"); + var templates = await session.Client.ListResourceTemplatesAsync().ConfigureAwait(false); - var result = await session.Client.ReadResourceAsync("repl://ops/status").ConfigureAwait(false); + templates.Should().ContainSingle(t => t.UriTemplate.Contains("{name}", StringComparison.Ordinal)).Which + .MimeType.Should().Be("application/json"); + } + } + + [TestMethod] + [Description("Resource reads bypass paged tool text summaries and keep serialized JSON content.")] + public async Task When_ResourceReadReturnsPageAndSummaryOnlyMode_Then_ReadStillReturnsJson() + { + var session = await McpTestFixture.CreateAsync( + app => app.Map("contacts", (IReplPagingContext paging) => + paging.Page( + new[] + { + new { Id = 1, Name = "Alice" }, + }, + nextCursor: "page-2", + totalCount: 2)) + .ReadOnly() + .AsResource(), + options => options.PagedResultTextMode = McpPagedResultTextMode.SummaryOnly).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + var result = await session.Client.ReadResourceAsync("repl://contacts").ConfigureAwait(false); var content = result.Contents.OfType().Single(); - content.MimeType.Should().Be("application/vnd.repl.status+json"); - content.Text.Should().Contain("checkout"); + content.MimeType.Should().Be("application/json"); + content.Text.Should().Contain("\"items\""); + content.Text.Should().Contain("page-2"); + content.Text.Should().NotContain("Returned 1 item(s)."); } } [TestMethod] - [Description("Undeclared resource templates use the forced MCP output converter MIME type.")] - public async Task When_TemplatedResourceHasNoDeclaredMimeType_Then_TemplateUsesForcedJsonMimeType() + [Description("Resource reads bypass the tool OK placeholder and keep the serialized JSON null payload.")] + public async Task When_ResourceHandlerProducesNoOutput_Then_ReadUsesSerializedJsonNull() { var session = await McpTestFixture.CreateAsync( - app => app.Map("docs {name}", (string name) => $"# {name}") + app => app.Map("noop", () => { }) .ReadOnly() .AsResource()).ConfigureAwait(false); await using (session.ConfigureAwait(false)) { - var templates = await session.Client.ListResourceTemplatesAsync().ConfigureAwait(false); - - templates.Should().ContainSingle(t => t.UriTemplate.Contains("{name}", StringComparison.Ordinal)).Which + var resources = await session.Client.ListResourcesAsync().ConfigureAwait(false); + resources.Should().ContainSingle(r => string.Equals(r.Uri, "repl://noop", StringComparison.Ordinal)).Which .MimeType.Should().Be("application/json"); + + var result = await session.Client.ReadResourceAsync("repl://noop").ConfigureAwait(false); + var content = result.Contents.OfType().Single(); + + content.MimeType.Should().Be("application/json"); + content.Text.Should().Be("null"); } } diff --git a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs index a60e629..5d03633 100644 --- a/src/Repl.Tests/Given_CommandBuilderEnrichment.cs +++ b/src/Repl.Tests/Given_CommandBuilderEnrichment.cs @@ -103,32 +103,6 @@ public void When_AsResourceIsCalled_Then_IsResourceIsTrue() command.IsResource.Should().BeTrue(); } - [TestMethod] - [Description("Verifies AsResource can declare an explicit MCP resource MIME type override.")] - public void When_AsResourceIsCalledWithMimeType_Then_ResourceMimeTypeIsStored() - { - var sut = CoreReplApp.Create(); - var command = sut.Map("contacts", () => "ok"); - - var chained = command.AsResource(mimeType: "application/vnd.repl.contacts+json"); - - chained.Should().BeSameAs(command); - command.IsResource.Should().BeTrue(); - command.ResourceMimeType.Should().Be("application/vnd.repl.contacts+json"); - } - - [TestMethod] - [Description("Verifies AsResource rejects empty MIME types.")] - public void When_AsResourceIsCalledWithEmptyMimeType_Then_Throws() - { - var sut = CoreReplApp.Create(); - var command = sut.Map("contacts", () => "ok"); - - var act = () => command.AsResource(mimeType: " "); - - act.Should().Throw(); - } - [TestMethod] [Description("Verifies AsPrompt marks the command as a prompt source.")] public void When_AsPromptIsCalled_Then_IsPromptIsTrue() @@ -189,45 +163,10 @@ public void When_DocumentationModelIsCreated_Then_EnrichedFieldsArePresent() cmd.Annotations.Should().NotBeNull(); cmd.Annotations!.ReadOnly.Should().BeTrue(); cmd.IsResource.Should().BeTrue(); - cmd.ResourceMimeType.Should().BeNull(); cmd.Metadata.Should().NotBeNull(); cmd.Metadata!["scope"].Should().Be("crm"); } - [TestMethod] - [Description("Verifies explicit resource MIME type override propagates through the documentation model.")] - public void When_ResourceMimeTypeIsConfigured_Then_DocumentationModelContainsIt() - { - var sut = CoreReplApp.Create(); - sut.Map("contacts", () => "ok") - .WithDescription("List contacts") - .AsResource(mimeType: "application/vnd.repl.contacts+json"); - - var model = sut.CreateDocumentationModel(); - - model.Commands.Should().ContainSingle(c => c.Path == "contacts").Which - .ResourceMimeType.Should().Be("application/vnd.repl.contacts+json"); - model.Resources.Should().ContainSingle(r => r.Path == "contacts").Which - .MimeType.Should().Be("application/vnd.repl.contacts+json"); - } - - [TestMethod] - [Description("Verifies an undeclared resource MIME type stays null in documentation so consumers can use their active converter.")] - public void When_ResourceMimeTypeIsNotConfigured_Then_DocumentationResourceMimeTypeIsNull() - { - var sut = CoreReplApp.Create(); - sut.Map("contacts", () => "ok") - .WithDescription("List contacts") - .AsResource(); - - var model = sut.CreateDocumentationModel(); - - model.Commands.Should().ContainSingle(c => c.Path == "contacts").Which - .ResourceMimeType.Should().BeNull(); - model.Resources.Should().ContainSingle(r => r.Path == "contacts").Which - .MimeType.Should().BeNull(); - } - [TestMethod] [Description("Verifies resources collection is populated from AsResource commands.")] public void When_CommandIsMarkedAsResource_Then_ResourcesCollectionContainsIt() From 3ece77390c148c6c8ef4c0938aead6f07680de9c Mon Sep 17 00:00:00 2001 From: Carl de Billy's personal bot - Autocarl Date: Thu, 25 Jun 2026 20:48:43 -0400 Subject: [PATCH 3/6] refactor: centralize MCP forced output MIME lookup --- src/Repl.Mcp/McpToolAdapter.cs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 9a25cb9..8131e52 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -34,14 +34,9 @@ internal string ForcedOutputMimeType { get { - if (_app is CoreReplApp coreApp - && coreApp.OptionsSnapshot.Output.Transformers.TryGetValue(ForcedOutputFormat, out var transformer) - && !string.IsNullOrWhiteSpace(transformer.MimeType)) - { - return transformer.MimeType; - } - - return TextPlainMimeType; + var coreApp = _app as CoreReplApp + ?? throw new InvalidOperationException("MCP tool adapter requires a CoreReplApp to resolve output metadata."); + return coreApp.OptionsSnapshot.Output.Transformers[ForcedOutputFormat].MimeType; } } From ae7f5931bd104572c3e8484167923001c8f9a4f2 Mon Sep 17 00:00:00 2001 From: Carl de Billy's personal bot - Autocarl Date: Thu, 25 Jun 2026 21:21:57 -0400 Subject: [PATCH 4/6] test: smoke MCP resources with inspector CLI --- docs/mcp-reference.md | 22 +++ docs/testing-toolkit.md | 4 + src/Repl.Mcp/McpToolAdapter.cs | 18 ++- src/Repl.McpTests/Given_McpInspectorCli.cs | 136 ++++++++++++++++++ .../Given_McpResourceParameters.cs | 67 ++++++++- src/Repl.McpTests/Repl.McpTests.csproj | 1 + 6 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 src/Repl.McpTests/Given_McpInspectorCli.cs diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index b275dfe..8a1e1a7 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -398,10 +398,32 @@ app.UseMcpServer(o => ### Debugging with MCP Inspector +Use the UI for interactive exploration: + ```bash npx @modelcontextprotocol/inspector myapp mcp serve ``` +Use CLI mode for repeatable smoke checks. `resources/list` exposes the advertised resource MIME type, and `resources/read` exposes the MIME type and body returned on the wire: + +```bash +# Build or publish the server first; this example uses a built sample DLL. +dotnet build samples/08-mcp-server/McpServerSample.csproj -c Release + +npx -y @modelcontextprotocol/inspector@0.22.0 --cli \ + dotnet samples/08-mcp-server/bin/Release/net10.0/McpServerSample.dll mcp serve \ + --method resources/list \ + | jq '.resources[] | { uri, mimeType }' + +npx -y @modelcontextprotocol/inspector@0.22.0 --cli \ + dotnet samples/08-mcp-server/bin/Release/net10.0/McpServerSample.dll mcp serve \ + --method resources/read \ + --uri repl://contacts \ + | jq '.contents[] | { uri, mimeType, text }' +``` + +For command-level tests, use `Repl.Testing`: `CommandExecution.GetResult()` validates the handler return value before rendering, while `OutputText` / `ReadJson()` validate rendered output. For MCP wire contracts such as `Resource.MimeType` and `TextResourceContents.MimeType`, use the MCP test fixture or an Inspector CLI smoke check because those values are protocol metadata, not `Repl.Testing` command results. + ## Client compatibility Feature support varies across agents. Check [mcp-availability.com](https://mcp-availability.com/) for current data. diff --git a/docs/testing-toolkit.md b/docs/testing-toolkit.md index 09b0130..73751c9 100644 --- a/docs/testing-toolkit.md +++ b/docs/testing-toolkit.md @@ -58,6 +58,10 @@ var ok = execution.TryGetResult(out var typed); Use `OutputText` when you intentionally validate the rendered contract (format/content as seen by users or external clients). For application tests, prefer `GetResult()` / `TryGetResult(...)` as the default assertion path. +`ReadJson()` is available when the rendered output is JSON and the test should validate the serialized representation. + +`Repl.Testing` intentionally validates the Repl command pipeline rather than MCP protocol metadata. +For MCP wire contracts such as resource `mimeType` values, use the MCP test fixture or an external smoke test such as MCP Inspector CLI. ### Semantic interaction events diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 8131e52..fe74631 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -34,9 +34,17 @@ internal string ForcedOutputMimeType { get { - var coreApp = _app as CoreReplApp - ?? throw new InvalidOperationException("MCP tool adapter requires a CoreReplApp to resolve output metadata."); - return coreApp.OptionsSnapshot.Output.Transformers[ForcedOutputFormat].MimeType; + if (_app is not CoreReplApp coreApp) + { + throw new InvalidOperationException("MCP tool adapter requires a CoreReplApp to resolve output metadata."); + } + + if (!coreApp.OptionsSnapshot.Output.Transformers.TryGetValue(ForcedOutputFormat, out var transformer)) + { + throw new InvalidOperationException("MCP server requires the 'json' output transformer."); + } + + return transformer.MimeType; } } @@ -148,7 +156,9 @@ internal async Task InvokeResourceAsync( if (string.IsNullOrWhiteSpace(invocation.Output)) { - return new McpResourceReadInvocation("OK", TextPlainMimeType, IsError: false); + // Results.Exit(0) without a payload intentionally renders no CLI output. + // Resource reads still need a body that matches the advertised forced JSON MIME type. + return new McpResourceReadInvocation("null", ForcedOutputMimeType, IsError: false); } return new McpResourceReadInvocation(invocation.Output, ForcedOutputMimeType, IsError: false); diff --git a/src/Repl.McpTests/Given_McpInspectorCli.cs b/src/Repl.McpTests/Given_McpInspectorCli.cs new file mode 100644 index 0000000..e14a27b --- /dev/null +++ b/src/Repl.McpTests/Given_McpInspectorCli.cs @@ -0,0 +1,136 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace Repl.McpTests; + +[TestClass] +public sealed class Given_McpInspectorCli +{ + private const string InspectorPackage = "@modelcontextprotocol/inspector@0.22.0"; + + [TestMethod] + [Description("End-to-end smoke guard: MCP Inspector sees command-backed resources as JSON and receives parseable JSON content.")] + public async Task When_InspectorReadsCommandBackedResource_Then_MimeTypeMatchesJsonPayload() + { + var serverDll = ResolveSampleServerDll(); + + var resourcesJson = await RunInspectorAsync( + serverDll, + ["--method", "resources/list"]).ConfigureAwait(false); + var readJson = await RunInspectorAsync( + serverDll, + ["--method", "resources/read", "--uri", "repl://contacts"]).ConfigureAwait(false); + + using var resources = JsonDocument.Parse(resourcesJson); + using var read = JsonDocument.Parse(readJson); + + AssertResourceMimeType(resources.RootElement, "repl://contacts", "application/json"); + AssertResourceMimeType(resources.RootElement, "repl://contacts/paged", "application/json"); + + var content = read.RootElement.GetProperty("contents").EnumerateArray().Single(); + content.GetProperty("uri").GetString().Should().Be("repl://contacts"); + content.GetProperty("mimeType").GetString().Should().Be("application/json"); + + var contacts = JsonDocument.Parse(content.GetProperty("text").GetString() ?? string.Empty); + contacts.RootElement.ValueKind.Should().Be(JsonValueKind.Array); + contacts.RootElement.EnumerateArray().First().GetProperty("name").GetString().Should().Be("Alice"); + } + + private static void AssertResourceMimeType(JsonElement root, string uri, string expectedMimeType) + { + var resource = root + .GetProperty("resources") + .EnumerateArray() + .Single(item => string.Equals(item.GetProperty("uri").GetString(), uri, StringComparison.Ordinal)); + resource.GetProperty("mimeType").GetString().Should().Be(expectedMimeType); + } + + private static async Task RunInspectorAsync(string serverDll, IReadOnlyList methodArguments) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + using var process = new Process + { + StartInfo = + { + FileName = OperatingSystem.IsWindows() ? "npx.cmd" : "npx", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }, + }; + + process.StartInfo.Environment["npm_config_loglevel"] = "silent"; + process.StartInfo.ArgumentList.Add("-y"); + process.StartInfo.ArgumentList.Add(InspectorPackage); + process.StartInfo.ArgumentList.Add("--cli"); + process.StartInfo.ArgumentList.Add("dotnet"); + process.StartInfo.ArgumentList.Add(serverDll); + process.StartInfo.ArgumentList.Add("mcp"); + process.StartInfo.ArgumentList.Add("serve"); + foreach (var argument in methodArguments) + { + process.StartInfo.ArgumentList.Add(argument); + } + + process.Start().Should().BeTrue(); + var stdoutTask = process.StandardOutput.ReadToEndAsync(timeout.Token); + var stderrTask = process.StandardError.ReadToEndAsync(timeout.Token); + + try + { + await process.WaitForExitAsync(timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + throw new TimeoutException("MCP Inspector CLI did not finish within 2 minutes."); + } + + var stdout = await stdoutTask.ConfigureAwait(false); + var stderr = await stderrTask.ConfigureAwait(false); + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"MCP Inspector CLI exited with {process.ExitCode}. Stdout: {stdout} Stderr: {stderr}"); + } + + return stdout; + } + + private static string ResolveSampleServerDll() + { + var root = ResolveRepositoryRoot(); + var configuration = new DirectoryInfo(AppContext.BaseDirectory).Parent?.Name ?? "Release"; + var serverDll = Path.Combine( + root, + "samples", + "08-mcp-server", + "bin", + configuration, + "net10.0", + "McpServerSample.dll"); + + if (!File.Exists(serverDll)) + { + throw new FileNotFoundException("The MCP sample server must be built before the Inspector smoke test runs.", serverDll); + } + + return serverDll; + } + + private static string ResolveRepositoryRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "src", "Repl.slnx"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new InvalidOperationException("Unable to resolve the repository root from the test assembly path."); + } +} diff --git a/src/Repl.McpTests/Given_McpResourceParameters.cs b/src/Repl.McpTests/Given_McpResourceParameters.cs index b1581c4..79b0ec1 100644 --- a/src/Repl.McpTests/Given_McpResourceParameters.cs +++ b/src/Repl.McpTests/Given_McpResourceParameters.cs @@ -1,3 +1,6 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; using ModelContextProtocol.Protocol; using Repl.Documentation; using Repl.Mcp; @@ -156,8 +159,8 @@ public async Task When_ResourceReadReturnsPageAndSummaryOnlyMode_Then_ReadStillR } [TestMethod] - [Description("Resource reads bypass the tool OK placeholder and keep the serialized JSON null payload.")] - public async Task When_ResourceHandlerProducesNoOutput_Then_ReadUsesSerializedJsonNull() + [Description("Void resource handlers are serialized by the forced JSON converter as JSON null, not tool fallback text.")] + public async Task When_ResourceHandlerReturnsVoid_Then_ReadUsesSerializedJsonNull() { var session = await McpTestFixture.CreateAsync( app => app.Map("noop", () => { }) @@ -178,6 +181,66 @@ public async Task When_ResourceHandlerProducesNoOutput_Then_ReadUsesSerializedJs } } + [TestMethod] + [Description("Exit results without payload keep the advertised resource MIME by returning a JSON null payload.")] + public async Task When_ResourceHandlerReturnsSuccessfulExitWithoutPayload_Then_ReadUsesSerializedJsonNull() + { + var session = await McpTestFixture.CreateAsync( + app => app.Map("silent", () => Results.Exit(0)) + .ReadOnly() + .AsResource()).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + var resources = await session.Client.ListResourcesAsync().ConfigureAwait(false); + resources.Should().ContainSingle(r => string.Equals(r.Uri, "repl://silent", StringComparison.Ordinal)).Which + .MimeType.Should().Be("application/json"); + + var result = await session.Client.ReadResourceAsync("repl://silent").ConfigureAwait(false); + var content = result.Contents.OfType().Single(); + + content.MimeType.Should().Be("application/json"); + content.Text.Should().Be("null"); + } + } + + [TestMethod] + [Description("Failed resource commands surface as MCP exceptions instead of typed resource contents.")] + public async Task When_ResourceCommandFails_Then_ReadThrowsMcpException() + { + var session = await McpTestFixture.CreateAsync( + app => app.Map("boom", () => Results.Error("boom", "nope")) + .ReadOnly() + .AsResource()).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + Func act = async () => await session.Client.ReadResourceAsync("repl://boom").ConfigureAwait(false); + + var exception = (await act.Should().ThrowAsync().ConfigureAwait(false)).Which; + exception.Message.Should().Contain("nope"); + } + } + + [TestMethod] + [Description("The resource adapter reports unknown routes as text/plain errors.")] + public async Task When_ResourceRouteIsUnknown_Then_AdapterReturnsTextError() + { + await using var services = new ServiceCollection().BuildServiceProvider(); + var adapter = new McpToolAdapter(ReplApp.Create().Core, new ReplMcpServerOptions(), services); + + var result = await adapter.InvokeResourceAsync( + "missing", + new Dictionary(StringComparer.Ordinal), + server: null, + progressToken: null, + ct: CancellationToken.None).ConfigureAwait(false); + + result.IsError.Should().BeTrue(); + result.MimeType.Should().Be("text/plain"); + result.Text.Should().Be("Unknown resource: missing"); + } + [TestMethod] [Description("Custom ResourceUriScheme is used in resource URIs.")] public async Task When_CustomScheme_Then_ResourceUriUsesScheme() diff --git a/src/Repl.McpTests/Repl.McpTests.csproj b/src/Repl.McpTests/Repl.McpTests.csproj index 077532d..a58a001 100644 --- a/src/Repl.McpTests/Repl.McpTests.csproj +++ b/src/Repl.McpTests/Repl.McpTests.csproj @@ -13,6 +13,7 @@ + From b18fc03b276f287454221f5c53e6b355decfdadd Mon Sep 17 00:00:00 2001 From: Carl de Billy's personal bot - Autocarl Date: Thu, 25 Jun 2026 22:16:56 -0400 Subject: [PATCH 5/6] fix: gate inspector smoke and isolate resource output --- docs/mcp-reference.md | 14 +- docs/testing-toolkit.md | 2 +- src/Repl.Mcp/McpToolAdapter.cs | 15 +- src/Repl.McpTests/Given_McpInspectorCli.cs | 132 +++++++++++++++--- .../Given_McpResourceParameters.cs | 26 ++++ 5 files changed, 164 insertions(+), 25 deletions(-) diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index 8a1e1a7..4ea3eb3 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -422,7 +422,19 @@ npx -y @modelcontextprotocol/inspector@0.22.0 --cli \ | jq '.contents[] | { uri, mimeType, text }' ``` -For command-level tests, use `Repl.Testing`: `CommandExecution.GetResult()` validates the handler return value before rendering, while `OutputText` / `ReadJson()` validate rendered output. For MCP wire contracts such as `Resource.MimeType` and `TextResourceContents.MimeType`, use the MCP test fixture or an Inspector CLI smoke check because those values are protocol metadata, not `Repl.Testing` command results. +The repository also includes an opt-in `dotnet test` smoke guard for this external toolchain: + +```bash +REPL_RUN_MCP_INSPECTOR_TESTS=1 \ + dotnet test src/Repl.McpTests/Repl.McpTests.csproj -c Release \ + --filter 'TestCategory=ExternalToolchain' +``` + +It is skipped by default so the normal .NET test suite stays hermetic and does not require Node/npm or npm registry access. + +For command-level tests, use `Repl.Testing`: `CommandExecution.GetResult()` validates the handler return value before rendering, while `OutputText` / `ReadJson()` validate rendered output. For MCP wire contracts such as `Resource.MimeType` and `TextResourceContents.MimeType`, use the MCP test fixture or the opt-in Inspector CLI smoke check because those values are protocol metadata, not `Repl.Testing` command results. + +Command-backed resources expose the rendered handler return value as the resource body. Low-level writes to `IReplIoContext.Output` are treated as side-channel command output and are not included in `resources/read` bodies. ## Client compatibility diff --git a/docs/testing-toolkit.md b/docs/testing-toolkit.md index 73751c9..2debd05 100644 --- a/docs/testing-toolkit.md +++ b/docs/testing-toolkit.md @@ -61,7 +61,7 @@ For application tests, prefer `GetResult()` / `TryGetResult(...)` as the d `ReadJson()` is available when the rendered output is JSON and the test should validate the serialized representation. `Repl.Testing` intentionally validates the Repl command pipeline rather than MCP protocol metadata. -For MCP wire contracts such as resource `mimeType` values, use the MCP test fixture or an external smoke test such as MCP Inspector CLI. +For MCP wire contracts such as resource `mimeType` values, use the MCP test fixture or the opt-in MCP Inspector CLI smoke test (`REPL_RUN_MCP_INSPECTOR_TESTS=1` + `TestCategory=ExternalToolchain`). ### Semantic interaction events diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index fe74631..b76d758 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -143,7 +143,13 @@ internal async Task InvokeResourceAsync( } var (tokens, prefills) = PrepareExecution(command, arguments); - var invocation = await ExecuteThroughPipelineAsync(tokens, prefills, server, progressToken, ct) + var invocation = await ExecuteThroughPipelineAsync( + tokens, + prefills, + server, + progressToken, + ct, + captureCommandOutput: false) .ConfigureAwait(false); if (invocation.ExitCode != 0) @@ -169,7 +175,8 @@ private async Task ExecuteThroughPipelineAsync( Dictionary prefills, McpServer? server, ProgressToken? progressToken, - CancellationToken ct) + CancellationToken ct, + bool captureCommandOutput = true) { var invocableApp = _app as ISubInvocableReplApp ?? throw new InvalidOperationException("MCP tool adapter requires an app that supports sub-invocation."); @@ -192,11 +199,15 @@ private async Task ExecuteThroughPipelineAsync( var effectiveTokens = new List(tokens.Count + 1) { $"--output:{ForcedOutputFormat}" }; effectiveTokens.AddRange(tokens); + // Command-backed resources expose the rendered return value as the resource body. + // Low-level handler writes to IReplIoContext.Output are side-channel output, not resource content. + var commandOutput = captureCommandOutput ? outputWriter : TextWriter.Null; using (ReplSessionIO.SetSession( output: outputWriter, input: inputReader, ansiMode: Rendering.AnsiMode.Never, sessionId: $"mcp-{Guid.NewGuid():N}", + commandOutput: commandOutput, isHostedSession: true)) { ReplSessionIO.IsProgrammatic = true; diff --git a/src/Repl.McpTests/Given_McpInspectorCli.cs b/src/Repl.McpTests/Given_McpInspectorCli.cs index e14a27b..2b3c95a 100644 --- a/src/Repl.McpTests/Given_McpInspectorCli.cs +++ b/src/Repl.McpTests/Given_McpInspectorCli.cs @@ -6,18 +6,36 @@ namespace Repl.McpTests; [TestClass] public sealed class Given_McpInspectorCli { + private const string EnableInspectorSmokeVariable = "REPL_RUN_MCP_INSPECTOR_TESTS"; private const string InspectorPackage = "@modelcontextprotocol/inspector@0.22.0"; [TestMethod] - [Description("End-to-end smoke guard: MCP Inspector sees command-backed resources as JSON and receives parseable JSON content.")] + [TestCategory("ExternalToolchain")] + [Description("Opt-in end-to-end smoke guard: MCP Inspector sees command-backed resources as JSON and receives parseable JSON content.")] public async Task When_InspectorReadsCommandBackedResource_Then_MimeTypeMatchesJsonPayload() { + if (!IsInspectorSmokeEnabled()) + { + Assert.Inconclusive( + $"Set {EnableInspectorSmokeVariable}=1 to run the MCP Inspector external-toolchain smoke test."); + } + + var npx = ResolveExecutable(OperatingSystem.IsWindows() ? "npx.cmd" : "npx") + ?? throw new InvalidOperationException( + $"{EnableInspectorSmokeVariable}=1 was set, but npx was not found on PATH."); + var dotnet = ResolveExecutable(OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet") + ?? throw new InvalidOperationException( + $"{EnableInspectorSmokeVariable}=1 was set, but dotnet was not found on PATH."); var serverDll = ResolveSampleServerDll(); var resourcesJson = await RunInspectorAsync( + npx, + dotnet, serverDll, ["--method", "resources/list"]).ConfigureAwait(false); var readJson = await RunInspectorAsync( + npx, + dotnet, serverDll, ["--method", "resources/read", "--uri", "repl://contacts"]).ConfigureAwait(false); @@ -30,10 +48,15 @@ public async Task When_InspectorReadsCommandBackedResource_Then_MimeTypeMatchesJ var content = read.RootElement.GetProperty("contents").EnumerateArray().Single(); content.GetProperty("uri").GetString().Should().Be("repl://contacts"); content.GetProperty("mimeType").GetString().Should().Be("application/json"); + using var resourceText = JsonDocument.Parse(content.GetProperty("text").GetString() ?? string.Empty); + resourceText.RootElement.ValueKind.Should().NotBe(JsonValueKind.Undefined); + } - var contacts = JsonDocument.Parse(content.GetProperty("text").GetString() ?? string.Empty); - contacts.RootElement.ValueKind.Should().Be(JsonValueKind.Array); - contacts.RootElement.EnumerateArray().First().GetProperty("name").GetString().Should().Be("Alice"); + private static bool IsInspectorSmokeEnabled() + { + var value = Environment.GetEnvironmentVariable(EnableInspectorSmokeVariable); + return string.Equals(value, "1", StringComparison.Ordinal) + || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); } private static void AssertResourceMimeType(JsonElement root, string uri, string expectedMimeType) @@ -45,25 +68,29 @@ private static void AssertResourceMimeType(JsonElement root, string uri, string resource.GetProperty("mimeType").GetString().Should().Be(expectedMimeType); } - private static async Task RunInspectorAsync(string serverDll, IReadOnlyList methodArguments) + private static async Task RunInspectorAsync( + string npx, + string dotnet, + string serverDll, + IReadOnlyList methodArguments) { using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2)); using var process = new Process { StartInfo = { - FileName = OperatingSystem.IsWindows() ? "npx.cmd" : "npx", + FileName = npx, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }, }; - process.StartInfo.Environment["npm_config_loglevel"] = "silent"; + ApplyMinimalEnvironment(process.StartInfo); process.StartInfo.ArgumentList.Add("-y"); process.StartInfo.ArgumentList.Add(InspectorPackage); process.StartInfo.ArgumentList.Add("--cli"); - process.StartInfo.ArgumentList.Add("dotnet"); + process.StartInfo.ArgumentList.Add(dotnet); process.StartInfo.ArgumentList.Add(serverDll); process.StartInfo.ArgumentList.Add("mcp"); process.StartInfo.ArgumentList.Add("serve"); @@ -72,7 +99,15 @@ private static async Task RunInspectorAsync(string serverDll, IReadOnlyL process.StartInfo.ArgumentList.Add(argument); } - process.Start().Should().BeTrue(); + try + { + process.Start(); + } + catch (System.ComponentModel.Win32Exception ex) + { + throw new InvalidOperationException($"Failed to start MCP Inspector via '{npx}'.", ex); + } + var stdoutTask = process.StandardOutput.ReadToEndAsync(timeout.Token); var stderrTask = process.StandardError.ReadToEndAsync(timeout.Token); @@ -97,25 +132,80 @@ private static async Task RunInspectorAsync(string serverDll, IReadOnlyL return stdout; } + private static void ApplyMinimalEnvironment(ProcessStartInfo startInfo) + { + startInfo.Environment.Clear(); + CopyEnvironmentVariable(startInfo, "PATH"); + CopyEnvironmentVariable(startInfo, "HOME"); + CopyEnvironmentVariable(startInfo, "USERPROFILE"); + CopyEnvironmentVariable(startInfo, "TMPDIR"); + CopyEnvironmentVariable(startInfo, "TMP"); + CopyEnvironmentVariable(startInfo, "TEMP"); + startInfo.Environment["CI"] = "true"; + startInfo.Environment["NO_COLOR"] = "1"; + startInfo.Environment["npm_config_loglevel"] = "silent"; + } + + private static void CopyEnvironmentVariable(ProcessStartInfo startInfo, string name) + { + var value = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrEmpty(value)) + { + startInfo.Environment[name] = value; + } + } + private static string ResolveSampleServerDll() { var root = ResolveRepositoryRoot(); var configuration = new DirectoryInfo(AppContext.BaseDirectory).Parent?.Name ?? "Release"; - var serverDll = Path.Combine( - root, - "samples", - "08-mcp-server", - "bin", - configuration, - "net10.0", - "McpServerSample.dll"); - - if (!File.Exists(serverDll)) + var sampleBin = Path.Combine(root, "samples", "08-mcp-server", "bin", configuration); + if (!Directory.Exists(sampleBin)) + { + throw new DirectoryNotFoundException( + $"The MCP sample server output directory does not exist: {sampleBin}"); + } + + var candidates = Directory + .EnumerateFiles(sampleBin, "McpServerSample.dll", SearchOption.AllDirectories) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}ref{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + .OrderByDescending(File.GetLastWriteTimeUtc) + .ToArray(); + if (candidates.Length == 0) { - throw new FileNotFoundException("The MCP sample server must be built before the Inspector smoke test runs.", serverDll); + throw new FileNotFoundException("The MCP sample server must be built before the Inspector smoke test runs."); + } + + return candidates[0]; + } + + private static string? ResolveExecutable(string executable) + { + if (Path.IsPathRooted(executable)) + { + return File.Exists(executable) ? executable : null; + } + + var paths = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var extensions = OperatingSystem.IsWindows() && string.IsNullOrEmpty(Path.GetExtension(executable)) + ? (Environment.GetEnvironmentVariable("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD") + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : [string.Empty]; + + foreach (var directory in paths) + { + foreach (var extension in extensions) + { + var candidate = Path.Combine(directory, executable + extension); + if (File.Exists(candidate)) + { + return Path.GetFullPath(candidate); + } + } } - return serverDll; + return null; } private static string ResolveRepositoryRoot() diff --git a/src/Repl.McpTests/Given_McpResourceParameters.cs b/src/Repl.McpTests/Given_McpResourceParameters.cs index 79b0ec1..30714e2 100644 --- a/src/Repl.McpTests/Given_McpResourceParameters.cs +++ b/src/Repl.McpTests/Given_McpResourceParameters.cs @@ -158,6 +158,32 @@ public async Task When_ResourceReadReturnsPageAndSummaryOnlyMode_Then_ReadStillR } } + [TestMethod] + [Description("Resource reads discard low-level handler output so application/json only labels the JSON return payload.")] + public async Task When_ResourceHandlerWritesSideChannelOutput_Then_ReadReturnsOnlyJsonPayload() + { + var session = await McpTestFixture.CreateAsync( + app => app.Map("mixed", (IReplIoContext io) => + { + io.Output.WriteLine("side-channel text"); + return new { Value = 42 }; + }) + .ReadOnly() + .AsResource(), + configureServices: _ => { }).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + var result = await session.Client.ReadResourceAsync("repl://mixed").ConfigureAwait(false); + var content = result.Contents.OfType().Single(); + + content.MimeType.Should().Be("application/json"); + content.Text.Should().NotContain("side-channel text"); + using var json = JsonDocument.Parse(content.Text); + json.RootElement.EnumerateObject().Single().Value.GetInt32().Should().Be(42); + } + } + [TestMethod] [Description("Void resource handlers are serialized by the forced JSON converter as JSON null, not tool fallback text.")] public async Task When_ResourceHandlerReturnsVoid_Then_ReadUsesSerializedJsonNull() From 6f03d26d880c6f1ebf5fdf31655b62deb9627caf Mon Sep 17 00:00:00 2001 From: Carl de Billy's personal bot - Autocarl Date: Thu, 25 Jun 2026 22:34:55 -0400 Subject: [PATCH 6/6] fix: keep resource diagnostics out of JSON bodies --- src/Repl.Mcp/McpToolAdapter.cs | 23 ++++++++++++++----- .../Given_McpResourceParameters.cs | 4 +++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index b76d758..f12d9b2 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -154,9 +154,17 @@ internal async Task InvokeResourceAsync( if (invocation.ExitCode != 0) { - var error = string.IsNullOrWhiteSpace(invocation.Output) - ? $"Command failed with exit code {invocation.ExitCode}." - : invocation.Output; + var error = invocation.Output; + if (string.IsNullOrWhiteSpace(error)) + { + error = invocation.Error; + } + + if (string.IsNullOrWhiteSpace(error)) + { + error = $"Command failed with exit code {invocation.ExitCode}."; + } + return new McpResourceReadInvocation(error, TextPlainMimeType, IsError: true); } @@ -182,6 +190,7 @@ private async Task ExecuteThroughPipelineAsync( ?? throw new InvalidOperationException("MCP tool adapter requires an app that supports sub-invocation."); var outputWriter = new StringWriter(); + var errorWriter = captureCommandOutput ? outputWriter : new StringWriter(); var inputReader = new StringReader(string.Empty); var feedback = _services.GetService(typeof(IMcpFeedback)) as IMcpFeedback; var interactionChannel = new McpInteractionChannel( @@ -200,7 +209,7 @@ private async Task ExecuteThroughPipelineAsync( effectiveTokens.AddRange(tokens); // Command-backed resources expose the rendered return value as the resource body. - // Low-level handler writes to IReplIoContext.Output are side-channel output, not resource content. + // Low-level handler writes to IReplIoContext.Output/Error are side-channel output, not resource content. var commandOutput = captureCommandOutput ? outputWriter : TextWriter.Null; using (ReplSessionIO.SetSession( output: outputWriter, @@ -208,6 +217,7 @@ private async Task ExecuteThroughPipelineAsync( ansiMode: Rendering.AnsiMode.Never, sessionId: $"mcp-{Guid.NewGuid():N}", commandOutput: commandOutput, + error: errorWriter, isHostedSession: true)) { ReplSessionIO.IsProgrammatic = true; @@ -215,13 +225,14 @@ private async Task ExecuteThroughPipelineAsync( effectiveTokens.ToArray(), mcpServices, ct).ConfigureAwait(false); var output = outputWriter.ToString().Trim(); - return new McpPipelineInvocation(output, exitCode); + var error = captureCommandOutput ? string.Empty : errorWriter.ToString().Trim(); + return new McpPipelineInvocation(output, error, exitCode); } } internal readonly record struct McpResourceReadInvocation(string Text, string MimeType, bool IsError); - private readonly record struct McpPipelineInvocation(string Output, int ExitCode); + private readonly record struct McpPipelineInvocation(string Output, string Error, int ExitCode); private static CallToolResult BuildToolResult(string output, int exitCode, McpPagedResultTextMode pagedTextMode) { diff --git a/src/Repl.McpTests/Given_McpResourceParameters.cs b/src/Repl.McpTests/Given_McpResourceParameters.cs index 30714e2..7983156 100644 --- a/src/Repl.McpTests/Given_McpResourceParameters.cs +++ b/src/Repl.McpTests/Given_McpResourceParameters.cs @@ -159,13 +159,14 @@ public async Task When_ResourceReadReturnsPageAndSummaryOnlyMode_Then_ReadStillR } [TestMethod] - [Description("Resource reads discard low-level handler output so application/json only labels the JSON return payload.")] + [Description("Resource reads discard low-level handler output and diagnostics so application/json only labels the JSON return payload.")] public async Task When_ResourceHandlerWritesSideChannelOutput_Then_ReadReturnsOnlyJsonPayload() { var session = await McpTestFixture.CreateAsync( app => app.Map("mixed", (IReplIoContext io) => { io.Output.WriteLine("side-channel text"); + io.Error.WriteLine("diagnostic text"); return new { Value = 42 }; }) .ReadOnly() @@ -179,6 +180,7 @@ public async Task When_ResourceHandlerWritesSideChannelOutput_Then_ReadReturnsOn content.MimeType.Should().Be("application/json"); content.Text.Should().NotContain("side-channel text"); + content.Text.Should().NotContain("diagnostic text"); using var json = JsonDocument.Parse(content.Text); json.RootElement.EnumerateObject().Single().Value.GetInt32().Should().Be(42); }