Add MCP server support — expose Repl apps as AI agent tools#10
Merged
carldebilly merged 36 commits intomainfrom Mar 19, 2026
Merged
Add MCP server support — expose Repl apps as AI agent tools#10carldebilly merged 36 commits intomainfrom
carldebilly merged 36 commits intomainfrom
Conversation
- Add CommandAnnotations record with behavioral flags (Destructive, ReadOnly, Idempotent, OpenWorld, LongRunning, AutomationHidden) - Add CommandAnnotationsBuilder as fluent escape hatch - Enrich CommandBuilder with annotation shortcuts using with-expressions, WithDetails, WithMetadata, AsResource, AsPrompt - Add IContextBuilder.WithDetails for rich context descriptions - Add ReplRuntimeChannel.Programmatic for agent/automation mode - Make CreateDocumentationModel public on ICoreReplApp with typed return - Enrich ReplDocCommand with Details, Annotations, Metadata, IsResource, IsPrompt fields - Add ReplDocResource record and Resources collection on ReplDocumentationModel - Fix route argument descriptions to pick up [Description] attributes from handler parameters - Auto-promote ReadOnly commands to Resources collection - Update README with MCP server feature highlights
Introduces the Repl.Mcp package following the same opt-in pattern as Repl.Spectre. The package bridges Repl's command graph to the Model Context Protocol via the ModelContextProtocol SDK 1.1.0. New package: Repl.Mcp - McpModule: registers `mcp serve` as a hidden protocol passthrough context (ShellCompletionModule pattern) - McpReplExtensions: AddReplMcpServer() / UseMcpServer() extension methods mirroring the Spectre integration pattern - ReplMcpServerOptions: server name, version, tool naming separator, interactivity mode, command filter, resource fallback, prompt registration - McpSchemaGenerator: generates JSON Schema from ReplDocCommand metadata with full type mapping (18 Repl types → JSON Schema) and annotation mapping (CommandAnnotations → MCP ToolAnnotations) - McpToolNameFlattener: flattens hierarchical route paths into flat MCP tool names, removing dynamic segments - McpToolAdapter: reconstructs CLI tokens from route templates and JSON arguments for pipeline dispatch (Phase 2: full pipeline integration pending) - ReplMcpServerTool: custom McpServerTool subclass with Repl-specific schema generation and pipeline dispatch - McpServerHandler: MCP server lifecycle orchestration via McpServer.Create + StdioServerTransport New test project: Repl.McpTests - Schema generation tests (type mapping, required/optional, enums, descriptions, annotation mapping) - Tool name flattening tests (separators, dynamic segment removal, collision patterns) - Token reconstruction tests (route args, named options, interaction prefills)
Implements the McpInteractionChannel with progressive degradation for runtime prompts in MCP mode (prefill → default → fail), and wires up McpToolAdapter to dispatch tool calls through the full Repl pipeline. McpInteractionChannel: - AskChoice/Confirmation/Text: prefill from tool arguments, fall back to defaults or fail with descriptive McpInteractionException - AskSecret: prefill-only (never elicitation/sampling for security) - AskMultiChoice: prefill with comma-separated values - ClearScreen: no-op, WriteProgress/WriteStatus: no-op (Phase 3: map to MCP progress notifications) - DispatchAsync<T>: throws NotSupportedException McpToolAdapter pipeline dispatch: - Creates scoped execution context per tool call with StringWriter output capture, StringReader input, AnsiMode.Never - Injects McpInteractionChannel via McpServiceProviderOverlay - Executes through CoreReplApp.RunWithServicesAsync with reconstructed CLI tokens - Returns captured output as CallToolResult Integration tests: - mcp serve route registration (default and custom command names) - Hidden context verification - AutomationHidden filtering - Enriched metadata propagation Interaction channel tests: - All interaction methods with prefill, defaults, and fail modes - Secret security (always fails without prefill) - Multi-choice comma parsing - DispatchAsync NotSupportedException
Extends McpServerHandler to generate MCP resources from AsResource() and ReadOnly commands, and collects MCP prompts from AsPrompt() commands and explicit options.Prompt() registrations (with override-on-collision semantics). McpServerHandler: - GenerateResources: creates McpServerResource instances with repl:// URI templates, dispatching reads through the tool adapter pipeline - CollectPrompts: merges AsPrompt() commands with options.Prompt() registrations; options.Prompt() wins on name collision - Capabilities are now conditional: Resources/Prompts capabilities only advertised when primitives exist - Refactored RunAsync into BuildServerOptions for readability Sample 08-mcp-server: - Full-featured sample demonstrating all MCP primitives: Resources (contacts list), Tools (CRUD with annotations), Prompts (explain-error), AutomationHidden (clear screen) - README with agent configuration for Claude Desktop, VS Code, and MCP Inspector
Connects the Repl interaction channel's WriteProgressAsync to MCP progress notifications via McpServer.NotifyProgressAsync. When a command reports progress during execution, the MCP client receives real-time progress updates with the progress token from the original tool call. - ReplMcpServerTool: extracts ProgressToken from RequestContext - McpToolAdapter: passes server + progressToken through to the interaction channel - McpInteractionChannel: reports progress via NotifyProgressAsync when server and progress token are available
Completes the McpInteractionChannel progressive degradation and connects remaining MCP server features. Elicitation (Tier 2): - AskChoiceAsync: uses UntitledSingleSelectEnumSchema - AskConfirmationAsync: uses BooleanSchema - AskTextAsync: uses StringSchema - Only active when InteractivityMode.PrefillThenElicitation and client advertises Elicitation capability Sampling (Tier 3): - Falls back to LLM sampling when elicitation is unavailable - Active for PrefillThenElicitation and PrefillThenSampling modes - Uses CreateMessageRequestParams with structured prompts WriteStatusAsync → MCP logging: - Maps to notifications/message with LoggingLevel.Info - Uses SendNotificationAsync with LoggingMessageNotificationParams list_changed on routing invalidation: - CoreReplApp.RoutingInvalidated event fires after InvalidateRouting() - McpServerHandler subscribes and refreshes the ToolCollection - Collection mutations auto-emit list_changed via the SDK - Properly unsubscribes on server shutdown
- Add Repl.Mcp/README.md (fixes NU5039 pack error) - Remove Condition on README.md pack include (Exists check unreliable during CI pack with different working directories) - Publish NuGet packages individually so a rejected package (e.g. new package awaiting validation) does not block the others - Re-runs are safe: --skip-duplicate already handles existing versions
Documentation:
- Add comprehensive docs/mcp-server.md covering all MCP features:
annotations, tool/resource/prompt guide, JSON Schema mapping,
interaction degradation, client compatibility matrix (per-agent
columns, dated March 2025), agent configuration for 4 clients,
NuGet MCP packaging, and full working example
- Rewrite src/Repl.Mcp/README.md as a concise teaser with link to
full docs
- Add MCP doc link to main README documentation table
API fixes:
- Rename CommandName → ContextName in ReplMcpServerOptions (it's a
context segment, not a command name)
- Fix WithDetails xmldoc: remove premature --help --verbose mentions;
Details is for agent descriptions and documentation export, not
terminal help (which doesn't support verbose mode yet)
Sample 08-mcp-server:
- Restructure with Context("contact", ...) pattern
- Replace explain-error prompt with troubleshoot prompt that clearly
demonstrates how prompts guide agent behavior
- Move UseMcpServer() before Map() calls to show order independence
- ReplMcpServerTool: LongRunning → Tool.Execution.TaskSupport = Optional (experimental SDK API, MCPEXP001 suppressed) - docs: expand OpenWorld — latency, failure modes, and security scope - docs: LongRunning shows actual MCP hint instead of "future" - docs: add "Why annotations matter" with parallelization guidance - docs: per-agent compatibility matrix with Codex and Overall column - docs: remove env/API key clutter from agent config examples - docs: rename CommandName → ContextName throughout
Addresses two bugs found in PR review: 1. Resources and prompts were generated but never added to McpServerOptions — only ToolCollection was populated. Now ResourceCollection and PromptCollection are populated too. 2. AsPrompt() commands returned the static description instead of dispatching through the Repl pipeline. Prompts now invoke the command handler with arguments via McpToolAdapter, so the handler return value becomes the prompt message text as documented.
Integration tests: - McpTestFixture: connects a real MCP client to the Repl MCP server via in-process pipes (System.IO.Pipelines) — no stdio - tools/list: verifies tool discovery, hidden/AutomationHidden exclusion, context flattening, JSON Schema correctness - tools/call: verifies pipeline dispatch, output capture, context command routing - Schema: verifies format hints (uuid), required fields, optional parameters, description combining Fix list_changed scope: - SubscribeToRoutingChanges now refreshes resources and prompts too, not just tools - Extracted RefreshCollection helper for consistent refresh pattern Fixes two PR review bugs: - Resources and prompts now registered in McpServerOptions (ResourceCollection + PromptCollection) - AsPrompt() commands now dispatch through the Repl pipeline instead of returning static description
The publish step tolerates individual package failures (new packages may need NuGet validation, re-runs use --skip-duplicate), but now throws when zero packages succeed — catching broken API keys or misconfigured sources.
Bugs fixed: - CI: $failed count now filters nupkg-only for the succeeded check (snupkg failures no longer cause negative count) - McpTestFixture: factory no longer leaks pipes and CTS — all resources are created as locals and passed into the constructor - Null dereference in E2E tests: explicit NotBeNull assertions before accessing TextContentBlock.Text Behavior fixes: - AsPrompt() commands are no longer exposed as tools (IsToolCandidate now checks !command.IsPrompt) - ReplRuntimeChannel.Programmatic is now produced by the runtime: McpToolAdapter sets ReplSessionIO.IsProgrammatic before executing, and CoreReplApp.ResolveCurrentRuntimeChannel checks it first - ResourceFallbackToTools: xmldoc now notes it's reserved for future implementation New tests: - E2E: AsPrompt commands excluded from tools/list - Integration: Programmatic channel excludes admin modules
Three new options in ReplMcpServerOptions: - ResourceFallbackToTools (default: false) — resources also appear as read-only tools for clients without resource support - PromptFallbackToTools (default: false) — prompts also appear as tools for clients without prompt support - AutoPromoteReadOnlyToResources (default: true) — ReadOnly commands are auto-promoted to resources; set false to require explicit AsResource() Tool generation now has three distinct phases: 1. Core tools (regular commands, ReadOnly+AsResource) 2. Resource fallback tools (resource-only commands, when opt-in) 3. Prompt fallback tools (prompt commands, when opt-in) Resource-only (.AsResource() without .ReadOnly()) commands are NOT tools by default — they're resources only. ReadOnly commands are always tools (the annotation is behavioral, not a primitive marker). Includes full test matrix (9 tests) covering all marker combinations and option interactions. Documentation updated with mapping matrix table and fallback usage example.
- AskConfirmationAsync now throws in PrefillThenFail mode when no prefill and defaultValue is false (consistent with other Ask methods) - Remove dead answer: prefix handling from ReconstructTokens — PrepareExecution already separates prefills upstream, so ReconstructTokens never receives answer: keys - Update tests to reflect the corrected separation of concerns
- McpToolAdapter._toolRoutes: use ConcurrentDictionary for thread-safe access during concurrent routing invalidation - CI: pass NuGet API key via env var instead of inline single-quoted string to avoid breakage if key contains special characters
- McpServerHandler.RunAsync: set IsProgrammatic = true before building the doc model so module presence predicates see Programmatic channel (not Cli which is the caller's context) - RoutingInvalidated handler: save/restore IsProgrammatic around doc model rebuild so the handler's caller context is not affected - ReplSessionIO.SetSession: save/restore IsProgrammatic in SessionScope alongside IsHostedSession, preventing leaks across nested sessions
improve collision detection, and document limitations Filtering fixes: - GenerateResources now applies IsToolCandidate — Hidden and AutomationHidden commands are excluded from resources/list - CollectPrompts now applies IsToolCandidate — Hidden and AutomationHidden commands are excluded from prompts/list Collision detection: - AddTool now uses Dictionary<name, path> to distinguish same-command re-registration (silently skipped) from different-route collisions (throws InvalidOperationException at startup with both paths) Known limitations documented: - Collection parameters (List<T>) not correctly bound from MCP - Parameterized resources cannot be invoked via resources/read New tests: Hidden/AutomationHidden filtering on resources and prompts, collision detection (same-phase and cross-phase), no-duplicate for ReadOnly+AsResource with fallback enabled.
and always-on capabilities
- ReconstructTokens: omit empty tokens for missing optional route
segments (report {period?} without period no longer emits "")
- McpSchemaGenerator: detect List<T>, IList<T>, T[] types and emit
{ type: array, items: { ... } } instead of { type: string }
- CollectPrompts: detect flattened-name collisions between different
prompt routes (same behavior as tools)
- BuildCapabilities: always advertise Tools, Resources, and Prompts
capabilities even when initial collections are empty — routing
invalidation may add them later and capabilities cannot be added
after the initialize handshake
- Document known limitation: avoid resources on conditional modules
ReplMcpServerPrompt (new): - Custom McpServerPrompt subclass that derives prompt arguments from the Repl doc model (route args + options) instead of reflecting the delegate signature. Agents now see the real parameters. - Surfaces execution failures as McpException instead of returning error text as a successful prompt result. Context WithDetails: - Wire ContextDefinition.Details through to ReplDocContext.Details so context-level WithDetails() is no longer a no-op. E2E tests: - prompts/list: verifies prompt arguments are derived from command parameters - prompts/get dispatch: deferred (requires full pipeline context) Replaces the old CreatePipelinePrompt/ConvertPromptArguments approach with a proper McpServerPrompt subclass.
McpTestFixture now uses McpServerHandler.BuildServerOptions (internal) instead of building the server manually. This exercises the full tool/resource/prompt generation pipeline with fallback options, filtering, and collision detection. New E2E tests (Given_McpFallbackEndToEnd): - Resource-only NOT a tool when fallback disabled - Resource-only IS a tool when fallback enabled - ReadOnly+AsResource always a tool regardless of fallback - Prompt-only NOT a tool when fallback disabled - Prompt-only IS a tool when fallback enabled - Prompt still in prompts/list with fallback enabled - Both fallbacks enabled: all commands visible as tools - AutomationHidden excluded even with fallbacks enabled
…merable<T> - McpToolAdapter.ClearRoutes: called before rebuilding on routing invalidation so removed commands are no longer callable - Resource callbacks: throw McpException when IsError is true instead of returning error text as successful content - McpSchemaGenerator: recognize IEnumerable<T> as array type
- ResolveChoiceIndex: add unambiguous prefix matching (exact first, then single prefix match) — same as console MatchChoiceByName - ParseBool: reject unrecognized values instead of coercing to false (accepts yes/no, true/false, y/n, 1/0 only) - ParseMultiChoice: enforce MinSelections/MaxSelections from AskMultiChoiceOptions
Add TransportFactory option to ReplMcpServerOptions for custom transports (WebSocket, SSE) and wire it through McpServerHandler.RunAsync. Add error path tests for ParseBool, prefix matching, and multi-choice min/max validation.
…ation throw, debounce - Restore IsProgrammatic AsyncLocal in RunAsync finally block - Emit DestructiveHint=false for ReadOnly tools (MCP spec defaults to true) - AskConfirmationAsync throws in PrefillThenFail regardless of defaultValue - Resolve DI-registered options in McpModule handler (AddReplMcpServer fix) - Replace O(n) FirstOrDefault lookups with dictionary in tool/resource generation - Debounce RoutingInvalidated to coalesce rapid Map() calls into single rebuild - Add lock around collection refresh to prevent concurrent interleaving
- Debounce test uses FakeTimeProvider with sync test method for deterministic time control (callbacks fire during Advance()) - Concurrent session test validates tool isolation between two independent MCP sessions running in parallel - McpServerHandler resolves TimeProvider from DI for testable timers
Replace the parameterless McpServerResource.Create callback with
ReplMcpServerResource — a custom subclass that extracts URI template
variables from the request URI and forwards them to the Repl pipeline.
Resources like `config {env}` now expose `repl://config/{env}` and
correctly pass `env=production` when read via `repl://config/production`.
New `ResourceUriScheme` option (default "repl") controls the URI scheme for resources. Apps can set e.g. `o.ResourceUriScheme = "myapp"` to get `myapp://status` instead of `repl://status`.
…assthrough - Wrap RebuildCollections in try/catch so timer callback exceptions don't crash the process — server continues with stale routes - Optimistic concurrency: build new routes in a temp adapter, then swap atomically under lock — no window where concurrent tool calls fail - Pass request.Server to prompt execution (was null, disabling elicitation) - Add test: server survives rebuild failure with stale routes - Fix null deref warning and empty catch comment from code quality reviews
Change `bool defaultValue = false` to `bool? defaultValue = null` across the interface and all implementations. This aligns with AskChoiceAsync (int?) and AskTextAsync (string?) — explicit defaults are returned before mode checks, null means "no default" and triggers PrefillThenFail throw.
Expose public API to build McpServerOptions from a Repl app's command graph without starting a server. Enables two advanced scenarios: - Stdio-over-anything: use TransportFactory with WebSocket/named pipes - MCP-over-HTTP: pass options to ASP.NET Core MapMcp or McpServer.Create Add docs/mcp-advanced.md with recipes for both patterns.
BuildMcpServerOptions enables N concurrent sessions over custom transports — create one McpServer per connection with shared options. Each session is isolated via AsyncLocal scopes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Enables any Repl app to become an MCP (Model Context Protocol) server with a single
app.UseMcpServer()call. AI agents like Claude Desktop, VS Code Copilot, and Cursor can then discover and invoke commands as typed tools with real JSON Schema, behavioral annotations, and progressive interaction degradation.One command graph. CLI, REPL, remote sessions, and AI agents — all from the same code.
What agents see
Commands become MCP tools with typed JSON Schema derived from route constraints and handler parameters. Annotations map directly to MCP hints that agents use for safety decisions:
.ReadOnly()readOnlyHint: true,destructiveHint: false— agent calls autonomously.Destructive()destructiveHint: true— agent asks for confirmation.Idempotent()idempotentHint: true— agent retries on failure.OpenWorld()openWorldHint: true— agent expects latency.AutomationHidden().AsResource()repl://config/{env}).AsPrompt().WithDetails(md)Route constraints (
{id:guid},{email:email},{when:date}) produce proper JSON Schema formats. Parameterized resources (e.g.config {env}) are exposed as URI templates — agents readrepl://config/productionand the parameters flow through the command pipeline.Runtime interaction in MCP mode
Commands that prompt users at runtime (
AskConfirmationAsync,AskChoiceAsync, etc.) degrade gracefully across four tiers:answer:confirm=yes)AskConfirmationAsyncacceptsbool? defaultValue = null— explicit defaults are returned before mode checks (consistent withAskChoiceAsyncandAskTextAsync).AskSecretAsyncis always prefill-only (security).WriteProgressAsyncmaps to real-time MCP progress notifications.WriteStatusAsyncmaps to MCP log messages.Custom transports & HTTP
The server supports three integration patterns:
myapp mcp servefor standard MCP clientsTransportFactoryoption for WebSocket, named pipes, SSH, etc.BuildMcpServerOptions()builds the MCP collections for use with ASP.NET Core or any HTTP hostMulti-session is supported in all patterns via
AsyncLocalsession isolation (same mechanism as hosted sessions). See docs/mcp-advanced.md for recipes.Architecture
The design splits into two layers:
Repl.Core) —CommandAnnotations,WithDetails,AsResource,AsPrompt,ReplRuntimeChannel.Programmatic, publicCreateDocumentationModel. Useful independently of MCP.Repl.Mcppackage — opt-in, referencesModelContextProtocolSDK. Reads the documentation model, generates JSON Schema, registers MCP tools/resources/prompts, dispatches tool calls through the existing Repl pipeline, and captures output.Tool calls execute through
CoreReplApp.RunWithServicesAsync— the same pipeline as CLI and interactive mode. No parallel code path.Reliability & performance
Map()calls coalesce into a single rebuild (100ms debounce via injectableTimeProvider)Configuration
Test plan
dotnet test --solution src/Repl.slnx)FakeTimeProvider(deterministic, sync)