Skip to content

Commit af2ca6d

Browse files
committed
Add declarative answer slots and force JSON output in MCP mode
- Force --output:json in McpToolAdapter so agents receive structured JSON instead of human-formatted tables and banners - Add WithAnswer() fluent API and [Answer] attribute to declare interactive prompt slots on commands (confirmation, choices, etc.) - Expose answer slots as answer:{name} properties in MCP tool schema so agents know which prefills are available before first call - Answer types reuse route constraint names (string, bool, int, etc.)
1 parent a8b70eb commit af2ca6d

12 files changed

Lines changed: 139 additions & 12 deletions

File tree

docs/mcp-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ Commands that use runtime prompts (`AskChoiceAsync`, `AskConfirmationAsync`, etc
206206

207207
| Tier | Mechanism | When |
208208
|---|---|---|
209-
| 1. Prefill | Values from tool arguments (`answer:confirm=yes`) | Always tried first |
209+
| 1. Prefill | Values from tool arguments (`answer.confirm=yes`) | Always tried first |
210210
| 2. Elicitation | Structured form request to user through agent client | `PrefillThenElicitation` mode + client supports it |
211211
| 3. Sampling | LLM answers on behalf of user | `PrefillThenElicitation` or `PrefillThenSampling` + client supports it |
212212
| 4. Default/Fail | Use default value or fail with descriptive error | Fallback |

samples/08-mcp-server/Program.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ The email must be unique across all contacts.
5454
return Results.Success($"Contact {id} deleted.");
5555
})
5656
.WithDescription("Delete a contact")
57-
.Destructive();
57+
.Destructive()
58+
.WithAnswer("confirm", "bool", "Confirm deletion");
5859
});
5960

6061
// ── Prompts (reusable agent instructions) ──────────────────────────

src/Repl.Core/AnswerDeclaration.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace Repl;
2+
3+
/// <summary>
4+
/// Declares an interactive answer slot that can be pre-filled via
5+
/// <c>--answer:{name}=value</c> on the CLI or <c>answer:{name}</c> in MCP tool calls.
6+
/// </summary>
7+
/// <param name="Name">Answer name (matches the <c>name</c> parameter in <c>AskConfirmationAsync</c>, etc.).</param>
8+
/// <param name="Type">Value type using route constraint names: <c>string</c>, <c>bool</c>, <c>int</c>, <c>guid</c>, <c>email</c>, etc.</param>
9+
/// <param name="Description">Optional description for help text and agent tool schemas.</param>
10+
public sealed record AnswerDeclaration(
11+
string Name,
12+
string Type = "string",
13+
string? Description = null);

src/Repl.Core/CommandBuilder.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ internal CommandBuilder(string route, Delegate handler)
8686
/// </summary>
8787
public bool IsPrompt { get; private set; }
8888

89+
/// <summary>
90+
/// Gets declared answer slots for interactive prompts.
91+
/// </summary>
92+
public IReadOnlyList<AnswerDeclaration> Answers => _answers;
93+
94+
private readonly List<AnswerDeclaration> _answers = [];
95+
8996
/// <summary>
9097
/// Gets generic metadata entries for extensibility.
9198
/// </summary>
@@ -231,6 +238,21 @@ public CommandBuilder WithMetadata(string key, object value)
231238
return this;
232239
}
233240

241+
/// <summary>
242+
/// Declares an interactive answer slot that can be pre-filled via <c>--answer:{name}=value</c>
243+
/// on the CLI or <c>answer:{name}</c> in MCP tool calls.
244+
/// </summary>
245+
/// <param name="name">Answer name (matches the <c>name</c> parameter in <c>AskConfirmationAsync</c>, <c>AskChoiceAsync</c>, etc.).</param>
246+
/// <param name="type">Value type using route constraint names: <c>string</c>, <c>bool</c>, <c>int</c>, <c>guid</c>, <c>email</c>, etc.</param>
247+
/// <param name="description">Optional description for help text and agent tool schemas.</param>
248+
/// <returns>The same builder instance.</returns>
249+
public CommandBuilder WithAnswer(string name, string type = "string", string? description = null)
250+
{
251+
ArgumentException.ThrowIfNullOrWhiteSpace(name);
252+
_answers.Add(new AnswerDeclaration(name, type, description));
253+
return this;
254+
}
255+
234256
// ── Annotation shortcuts ───────────────────────────────────────────
235257
// Same style as Hidden() — short, chainable, directly on CommandBuilder.
236258
// Uses `with` expressions to preserve record immutability.

src/Repl.Core/CoreReplApp.Documentation.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,8 @@ private ReplDocCommand BuildDocumentationCommand(RouteDefinition route)
234234
});
235235
var options = regularOptions.Concat(groupOptions).ToArray();
236236

237+
var answers = BuildDocumentationAnswers(route.Command);
238+
237239
return new ReplDocCommand(
238240
Path: route.Template.Template,
239241
Description: route.Command.Description,
@@ -244,10 +246,25 @@ private ReplDocCommand BuildDocumentationCommand(RouteDefinition route)
244246
Details: route.Command.Details,
245247
Annotations: route.Command.Annotations,
246248
Metadata: route.Command.Metadata.Count > 0 ? route.Command.Metadata : null,
249+
Answers: answers.Length > 0 ? answers : null,
247250
IsResource: route.Command.IsResource,
248251
IsPrompt: route.Command.IsPrompt);
249252
}
250253

254+
private static ReplDocAnswer[] BuildDocumentationAnswers(CommandBuilder command)
255+
{
256+
var fluentAnswers = command.Answers
257+
.Select(a => new ReplDocAnswer(a.Name, a.Type, a.Description));
258+
var attributeAnswers = command.Handler.Method
259+
.GetCustomAttributes<AnswerAttribute>()
260+
.Select(a => new ReplDocAnswer(a.Name, a.Type, a.Description));
261+
return fluentAnswers
262+
.Concat(attributeAnswers)
263+
.GroupBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
264+
.Select(g => g.First())
265+
.ToArray();
266+
}
267+
251268
private ReplDocApp BuildDocumentationApp()
252269
{
253270
var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace Repl.Documentation;
2+
3+
/// <summary>
4+
/// Answer slot metadata for commands with interactive prompts.
5+
/// </summary>
6+
public sealed record ReplDocAnswer(
7+
string Name,
8+
string Type,
9+
string? Description);

src/Repl.Core/Documentation/ReplDocCommand.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ public sealed record ReplDocCommand(
1313
string? Details = null,
1414
CommandAnnotations? Annotations = null,
1515
IReadOnlyDictionary<string, object>? Metadata = null,
16+
IReadOnlyList<ReplDocAnswer>? Answers = null,
1617
bool IsResource = false,
1718
bool IsPrompt = false);

src/Repl.Core/HelpTextBuilder.cs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,17 +265,18 @@ private static string BuildSingleCommandHelp(RouteDefinition route, bool useAnsi
265265
: $"{Environment.NewLine}Aliases: {string.Join(", ", route.Command.Aliases)}";
266266
var argumentSection = BuildArgumentSection(route, useAnsi, palette);
267267
var optionSection = BuildOptionSection(route, useAnsi, palette);
268+
var answerSection = BuildAnswerSection(route, useAnsi, palette);
268269
if (!useAnsi)
269270
{
270-
return $"Usage: {displayTemplate}{Environment.NewLine}Description: {description}{aliases}{argumentSection}{optionSection}";
271+
return $"Usage: {displayTemplate}{Environment.NewLine}Description: {description}{aliases}{argumentSection}{optionSection}{answerSection}";
271272
}
272273

273274
var usage = $"{AnsiText.Apply("Usage:", palette.SectionStyle)} {AnsiText.Apply(displayTemplate, palette.CommandStyle)}";
274275
var desc = $"{AnsiText.Apply("Description:", palette.SectionStyle)} {AnsiText.Apply(description, palette.DescriptionStyle)}";
275276
var aliasText = route.Command.Aliases.Count == 0
276277
? string.Empty
277278
: $"{Environment.NewLine}{AnsiText.Apply("Aliases:", palette.SectionStyle)} {AnsiText.Apply(string.Join(", ", route.Command.Aliases), palette.CommandStyle)}";
278-
return $"{usage}{Environment.NewLine}{desc}{aliasText}{argumentSection}{optionSection}";
279+
return $"{usage}{Environment.NewLine}{desc}{aliasText}{argumentSection}{optionSection}{answerSection}";
279280
}
280281

281282
private static string BuildArgumentSection(RouteDefinition route, bool useAnsi, AnsiPalette palette)
@@ -321,6 +322,31 @@ private static string BuildArgumentSection(RouteDefinition route, bool useAnsi,
321322
return builder.ToString();
322323
}
323324

325+
private static string BuildAnswerSection(RouteDefinition route, bool useAnsi, AnsiPalette palette)
326+
{
327+
if (route.Command.Answers.Count == 0)
328+
{
329+
return string.Empty;
330+
}
331+
332+
var builder = new StringBuilder();
333+
builder.AppendLine();
334+
builder.Append(useAnsi
335+
? AnsiText.Apply("Answers:", palette.SectionStyle)
336+
: "Answers:");
337+
foreach (var answer in route.Command.Answers)
338+
{
339+
var token = $"--answer:{answer.Name}";
340+
var desc = answer.Description ?? $"({answer.Type})";
341+
builder.AppendLine();
342+
builder.Append(useAnsi
343+
? $" {AnsiText.Apply(token, palette.CommandStyle)} {AnsiText.Apply(desc, palette.DescriptionStyle)}"
344+
: $" {token} {desc}");
345+
}
346+
347+
return builder.ToString();
348+
}
349+
324350
private static string BuildOptionSection(RouteDefinition route, bool useAnsi, AnsiPalette palette)
325351
{
326352
var parameters = route.Command.Handler.Method.GetParameters()
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace Repl;
2+
3+
/// <summary>
4+
/// Declares an interactive answer slot on a command handler method.
5+
/// Equivalent to calling <see cref="CommandBuilder.WithAnswer"/> in the fluent API.
6+
/// </summary>
7+
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
8+
public sealed class AnswerAttribute(string name, string type = "string") : Attribute
9+
{
10+
/// <summary>Answer name (matches the <c>name</c> parameter in <c>AskConfirmationAsync</c>, etc.).</summary>
11+
public string Name { get; } = name;
12+
13+
/// <summary>Value type using route constraint names: <c>string</c>, <c>bool</c>, <c>int</c>, etc.</summary>
14+
public string Type { get; } = type;
15+
16+
/// <summary>Optional description for help text and agent tool schemas.</summary>
17+
public string? Description { get; set; }
18+
}

src/Repl.Mcp/McpInteractionChannel.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ public async ValueTask<int> AskChoiceAsync(
6464

6565
throw new McpInteractionException(
6666
$"Interactive prompt '{name}' requires a value. " +
67-
$"Provide it as a tool argument 'answer:{name}'. Choices: {string.Join(", ", choices)}");
67+
$"Provide it as a tool argument 'answer.{name}'. Choices: {string.Join(", ", choices)}");
6868
}
6969

7070
public async ValueTask<bool> AskConfirmationAsync(
@@ -100,7 +100,7 @@ public async ValueTask<bool> AskConfirmationAsync(
100100

101101
throw new McpInteractionException(
102102
$"Interactive prompt '{name}' requires a value. " +
103-
$"Provide it as a tool argument 'answer:{name}' (true/false).");
103+
$"Provide it as a tool argument 'answer.{name}' (true/false).");
104104
}
105105

106106
public async ValueTask<string> AskTextAsync(
@@ -136,7 +136,7 @@ public async ValueTask<string> AskTextAsync(
136136

137137
throw new McpInteractionException(
138138
$"Interactive prompt '{name}' requires a value. " +
139-
$"Provide it as a tool argument 'answer:{name}'.");
139+
$"Provide it as a tool argument 'answer.{name}'.");
140140
}
141141

142142
public ValueTask<string> AskSecretAsync(
@@ -152,7 +152,7 @@ public ValueTask<string> AskSecretAsync(
152152

153153
throw new McpInteractionException(
154154
$"Secret prompt '{name}' requires a prefilled value. " +
155-
$"Provide it as a tool argument 'answer:{name}'.");
155+
$"Provide it as a tool argument 'answer.{name}'.");
156156
}
157157

158158
public async ValueTask<IReadOnlyList<int>> AskMultiChoiceAsync(
@@ -185,7 +185,7 @@ public async ValueTask<IReadOnlyList<int>> AskMultiChoiceAsync(
185185

186186
throw new McpInteractionException(
187187
$"Interactive prompt '{name}' requires a value. " +
188-
$"Provide it as a tool argument 'answer:{name}' (comma-separated). " +
188+
$"Provide it as a tool argument 'answer.{name}' (comma-separated). " +
189189
$"Choices: {string.Join(", ", choices)}");
190190
}
191191

0 commit comments

Comments
 (0)