Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/mcp-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<T>()` validates the handler return value before rendering, while `OutputText` / `ReadJson<T>()` 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.
Expand Down
4 changes: 4 additions & 0 deletions docs/testing-toolkit.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ var ok = execution.TryGetResult<Contact>(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<T>()` / `TryGetResult<T>(...)` as the default assertion path.
`ReadJson<T>()` 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

Expand Down
5 changes: 5 additions & 0 deletions src/Repl.Core/IOutputTransformer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ public interface IOutputTransformer
/// </summary>
string Name { get; }

/// <summary>
/// Gets the MIME type produced by this transformer.
/// </summary>
string MimeType => "text/plain";

/// <summary>
/// Gets a value indicating whether this transformer can be displayed by the interactive result pager.
/// </summary>
Expand Down
2 changes: 2 additions & 0 deletions src/Repl.Core/Output/HumanOutputTransformer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public HumanOutputTransformer(Func<HumanRenderSettings> resolveRenderSettings)

public string Name => "human";

public string MimeType => "text/plain";

public bool SupportsInteractivePaging => true;

public ValueTask<string> TransformAsync(object? value, CancellationToken cancellationToken = default)
Expand Down
2 changes: 2 additions & 0 deletions src/Repl.Core/Output/JsonOutputTransformer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ internal sealed class JsonOutputTransformer(JsonSerializerOptions serializerOpti
{
public string Name => "json";

public string MimeType => "application/json";

public ValueTask<string> TransformAsync(object? value, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Expand Down
2 changes: 2 additions & 0 deletions src/Repl.Core/Output/MarkdownOutputTransformer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ internal sealed class MarkdownOutputTransformer : IOutputTransformer

public string Name => "markdown";

public string MimeType => "text/markdown";

public ValueTask<string> TransformAsync(object? value, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Expand Down
2 changes: 2 additions & 0 deletions src/Repl.Core/Output/XmlOutputTransformer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ internal sealed class XmlOutputTransformer(JsonSerializerOptions serializerOptio
{
public string Name => "xml";

public string MimeType => "application/xml";

public ValueTask<string> TransformAsync(object? value, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Expand Down
2 changes: 2 additions & 0 deletions src/Repl.Core/Output/YamlOutputTransformer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ internal sealed class YamlOutputTransformer(JsonSerializerOptions serializerOpti
{
public string Name => "yaml";

public string MimeType => "application/yaml";

public ValueTask<string> TransformAsync(object? value, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Expand Down
8 changes: 7 additions & 1 deletion src/Repl.Mcp/McpServerHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,7 @@ private List<McpServerResource> GenerateResources(
Dictionary<string, ReplDocCommand> commandsByPath)
{
var resources = new List<McpServerResource>();
var resourceMimeType = adapter.ForcedOutputMimeType;

foreach (var resource in model.Resources)
{
Expand Down Expand Up @@ -854,7 +855,12 @@ private List<McpServerResource> 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)
{
Expand Down
105 changes: 95 additions & 10 deletions src/Repl.Mcp/McpToolAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ namespace Repl.Mcp;
/// </summary>
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;
Expand All @@ -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;
}
}

/// <summary>
/// Clears all registered routes. Called before rebuilding on routing invalidation.
/// </summary>
Expand Down Expand Up @@ -96,21 +117,80 @@ public async Task<CallToolResult> 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<CallToolResult> ExecuteThroughPipelineAsync(
internal async Task<McpResourceReadInvocation> InvokeResourceAsync(
string resourceName,
IDictionary<string, JsonElement> 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);
Comment thread
carldebilly marked this conversation as resolved.
}

private async Task<McpPipelineInvocation> ExecuteThroughPipelineAsync(
List<string> tokens,
Dictionary<string, string> 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(
Expand All @@ -125,30 +205,35 @@ private async Task<CallToolResult> ExecuteThroughPipelineAsync(
?.PushProgressToken(progressToken);

// Force JSON output — agents consume structured data, not human tables/banners.
var effectiveTokens = new List<string>(tokens.Count + 1) { "--output:json" };
var effectiveTokens = new List<string>(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))
Comment thread
carldebilly marked this conversation as resolved.
{
ReplSessionIO.IsProgrammatic = true;
var exitCode = await invocableApp.RunSubInvocationAsync(
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))
Expand Down
25 changes: 12 additions & 13 deletions src/Repl.Mcp/ReplMcpServerResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -66,32 +70,27 @@ public override async ValueTask<ReadResourceResult> 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<TextContentBlock>().FirstOrDefault()?.Text
?? "Resource read failed.";
throw new McpException(errorText);
throw new McpException(result.Text);
}

var text = result.Content?.OfType<TextContentBlock>().FirstOrDefault()?.Text ?? "";
return new ReadResourceResult
{
Contents =
[
new TextResourceContents
{
Uri = request.Params.Uri,
MimeType = "text/plain",
Text = text,
MimeType = result.MimeType,
Text = result.Text,
},
],
};
Expand Down
Loading
Loading