diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index d6f9954..4ea3eb3 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 @@ -390,10 +398,44 @@ 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 }' +``` + +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 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..2debd05 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 the opt-in MCP Inspector CLI smoke test (`REPL_RUN_MCP_INSPECTOR_TESTS=1` + `TestCategory=ExternalToolchain`). ### Semantic interaction events 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..2ff84eb 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 resourceMimeType = adapter.ForcedOutputMimeType; 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, + resourceMimeType); if (docCommand is not null) { diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 132153f..f12d9b2 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -14,6 +14,9 @@ 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; private readonly IServiceProvider _services; @@ -27,6 +30,24 @@ public McpToolAdapter(ICoreReplApp app, ReplMcpServerOptions options, IServicePr _services = services; } + internal string ForcedOutputMimeType + { + get + { + 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; + } + } + /// /// Clears all registered routes. Called before rebuilding on routing invalidation. /// @@ -96,21 +117,80 @@ 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, + captureCommandOutput: false) + .ConfigureAwait(false); + + if (invocation.ExitCode != 0) + { + 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); + } + + if (string.IsNullOrWhiteSpace(invocation.Output)) + { + // 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); + } + + private async Task ExecuteThroughPipelineAsync( List tokens, 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."); 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( @@ -125,14 +205,19 @@ 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); + // Command-backed resources expose the rendered return value as the resource body. + // 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, input: inputReader, ansiMode: Rendering.AnsiMode.Never, sessionId: $"mcp-{Guid.NewGuid():N}", + commandOutput: commandOutput, + error: errorWriter, isHostedSession: true)) { ReplSessionIO.IsProgrammatic = true; @@ -140,15 +225,15 @@ 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); + 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, string Error, 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 5421769..bd97970 100644 --- a/src/Repl.Mcp/ReplMcpServerResource.cs +++ b/src/Repl.Mcp/ReplMcpServerResource.cs @@ -14,6 +14,7 @@ namespace Repl.Mcp; internal sealed partial class ReplMcpServerResource : McpServerResource { private readonly string _resourceName; + private readonly string _mimeType; private readonly McpToolAdapter _adapter; private readonly ResourceTemplate _protocolResourceTemplate; private readonly Regex? _uriParser; @@ -23,16 +24,19 @@ public ReplMcpServerResource( ReplDocResource resource, string resourceName, string uriTemplate, - McpToolAdapter adapter) + McpToolAdapter adapter, + string mimeType) { + ArgumentException.ThrowIfNullOrWhiteSpace(mimeType); _resourceName = resourceName; + _mimeType = mimeType; _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. @@ -66,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 = @@ -90,8 +89,8 @@ public override async ValueTask ReadAsync( new TextResourceContents { Uri = request.Params.Uri, - MimeType = "text/plain", - Text = text, + MimeType = result.MimeType, + Text = result.Text, }, ], }; diff --git a/src/Repl.McpTests/Given_McpInspectorCli.cs b/src/Repl.McpTests/Given_McpInspectorCli.cs new file mode 100644 index 0000000..2b3c95a --- /dev/null +++ b/src/Repl.McpTests/Given_McpInspectorCli.cs @@ -0,0 +1,226 @@ +using System.Diagnostics; +using System.Text.Json; + +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] + [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); + + 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"); + using var resourceText = JsonDocument.Parse(content.GetProperty("text").GetString() ?? string.Empty); + resourceText.RootElement.ValueKind.Should().NotBe(JsonValueKind.Undefined); + } + + 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) + { + 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 npx, + string dotnet, + string serverDll, + IReadOnlyList methodArguments) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + using var process = new Process + { + StartInfo = + { + FileName = npx, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }, + }; + + 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(serverDll); + process.StartInfo.ArgumentList.Add("mcp"); + process.StartInfo.ArgumentList.Add("serve"); + foreach (var argument in methodArguments) + { + process.StartInfo.ArgumentList.Add(argument); + } + + 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); + + 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 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 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."); + } + + 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 null; + } + + 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 c557692..7983156 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; @@ -48,7 +51,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(); @@ -69,12 +77,198 @@ 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("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("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"); + } + } + + [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/json"); + content.Text.Should().Contain("\"items\""); + content.Text.Should().Contain("page-2"); + content.Text.Should().NotContain("Returned 1 item(s)."); + } + } + + [TestMethod] + [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() + .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"); + content.Text.Should().NotContain("diagnostic 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() + { + var session = await McpTestFixture.CreateAsync( + app => app.Map("noop", () => { }) + .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://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"); + } + } + + [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 @@ + 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_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); + } +}