Attributes & tools
A handful of attributes cover the whole surface: publish, exclude, shape individual inputs, and shape what a tool returns — plus the ability to publish one action as several differently shaped tools.
The attribute surface
| Attribute | Target | Purpose |
|---|---|---|
[McpTool] | action | Publishes the action as a tool. Repeatable — see One action, several tools. |
[McpTool] | controller | Publishes every action on the controller. |
[McpIgnore] | controller, action, parameter, property | Excludes it. Always wins. |
[McpParameter] | parameter, property | Overrides the name, description, requiredness or example of one input. |
[McpToolOutput] | controller, action | Shapes what the tool returns: hides fields or transforms the response with a converter. Repeatable — see Shaping tool output. |
For Minimal APIs the same declarations are made with the .McpTool() /
.McpIgnore() / .McpToolOutput() endpoint conventions (or the same
attributes on the handler delegate) — see Minimal APIs.
[McpTool] also accepts Name, Title, Description,
Enabled, and the four MCP behaviour hints ReadOnly, Destructive,
Idempotent and OpenWorld. The hints default to the HTTP semantics of the
verb — GET is read-only and idempotent, DELETE and PUT are destructive — and any hint you set
explicitly overrides that default.
[HttpPost("{id:guid}/publish")]
[McpTool(
Name = "publish_article",
Description = "Publishes a draft article so it becomes visible to readers.",
Idempotent = false,
Destructive = true)]
public IActionResult Publish(Guid id) => ...
One action, several tools
A single endpoint is often the wrong shape for a model. GET /api/todos with five
optional filters is easy for a client that already knows what it wants and awkward for a model that
has to guess. Apply [McpTool] more than once and the same action is published as
several tools, each with its own name, description and parameter set — without adding controller
actions, and with the same pipeline replay behind every one of them.
[HttpGet]
[McpTool] // the full endpoint, unchanged
[McpTool("todos_list_open",
Title = "List open todos",
Description = "Lists the todo items that are still open.",
ExcludeParameters = new[] { "search", "priority" },
ConstantParameters = new[] { "isCompleted=false" })]
[McpTool("todos_search",
Title = "Search todos",
Description = "Searches the signed-in user's todo items by title and notes.",
IncludeParameters = new[] { "search", "page", "pageSize" },
RequiredParameters = new[] { "search" })]
public ActionResult<TodoPage> List(
[FromQuery] bool? isCompleted,
[FromQuery] TodoPriority? priority,
[FromQuery] string? search,
[FromQuery] int page = 0,
[FromQuery] int pageSize = 20) => ...
tools/list now advertises three tools over one action: todos_list takes
all five filters, todos_list_open takes only page and
pageSize and can never return completed items, and todos_search takes a
mandatory search plus paging.
| Property | Effect |
|---|---|
IncludeParameters | Whitelist. Only these inputs are exposed; everything else is left unset. |
ExcludeParameters | Hides inputs. They are left unset, so the action's own defaults apply. |
ConstantParameters | name=value pairs. The input disappears from the schema and the value is always sent. |
RequiredParameters | Marks inputs required for this tool even if the action treats them as optional. |
OptionalParameters | The reverse. Route tokens the URL cannot be built without stay required. |
Notes
- Names match either the tool input name (camelCase, as it appears in the schema) or the underlying binding name, case-insensitively. A name that matches nothing is logged as a warning.
- Constant values are converted to the parameter's CLR type:
pageSize=100becomes a JSON number,isCompleted=falsea JSON boolean, a complex parameter accepts a JSON literal such astags=["docs","ops"], and everything else is sent as a string. On a non-string parameter an empty value ornullmeans "send nothing". - A constant always wins over an argument that happens to target the same place, so a pinned value cannot be talked out of by the model.
- Route tokens can be pinned too, which is how an endpoint collapses into a zero-argument tool:
Hiding a route token without pinning it would produce a tool whose URL cannot be built, so Nabu logs a warning and skips that variant rather than publishing something uncallable.[HttpGet("{city}")] [McpTool] [McpTool("weather_get_yerevan_week", ConstantParameters = new[] { "city=Yerevan", "days=7" })] public ActionResult<IEnumerable<Forecast>> GetForecast(string city, [FromQuery] int days = 3) => ... - Give every extra variant an explicit
Name. Variants without one fall back to the generatedcontroller_actionname and collide, and all but the first end up with a_2,_3, ... suffix. - Variants declared on an action replace a controller-wide
[McpTool]rather than adding to it.
Shaping tool output
An action's response is often wider than what a model should see: internal identifiers, owner
fields, audit metadata, payloads sized for a UI rather than a context window.
[McpToolOutput] shapes the JSON a tool returns after the pipeline has run —
the HTTP API keeps its contract, and only MCP clients get the narrowed view.
[HttpGet]
[McpTool("todos_list")]
[McpTool("todos_list_compact")]
[McpToolOutput(Tool = "todos_list_compact",
IncludeFields = new[] { "items.id", "items.title", "totalCount" })]
public ActionResult<TodoPage> List(...) => ...
| Property | Effect |
|---|---|
IncludeFields | Keeps only the listed field paths; every other property is removed. |
ExcludeFields | Removes the listed field paths. Applied after IncludeFields. |
Converter | An IMcpToolOutputConverter type that transforms the (already filtered) JSON. |
Tool | Scopes the occurrence to one tool of a multi-[McpTool] action. Unset applies to all of them. |
Field paths are dot-separated JSON property names, matched case-insensitively, and arrays are
traversed transparently: items.owner removes owner from every element of
items. Both the text content and structuredContent are shaped. The
attribute is repeatable, so each variant of an action can publish its own view of the same
response; a method-level occurrence overrides a controller-level one, and within one level a
Tool-scoped occurrence wins over an unscoped one.
For transformations that field lists cannot express, name a converter:
[McpTool("todos_get_summary")]
[McpToolOutput(Tool = "todos_get_summary",
ExcludeFields = new[] { "attachments" },
Converter = typeof(TodoSummaryOutputConverter))]
public ActionResult<TodoItem> GetById(Guid id) => ...
public sealed class TodoSummaryOutputConverter : IMcpToolOutputConverter
{
public JsonNode? Convert(McpToolOutputContext context, JsonNode? output)
{
var item = output!.AsObject();
return new JsonObject
{
["id"] = item["id"]?.DeepClone(),
["summary"] = item["title"]?.GetValue<string>(),
};
}
}
Converters run after the field filters, are resolved from the request's services when registered —
so they can take dependencies — and are constructed with ActivatorUtilities otherwise.
Returning null exposes an empty result.
Notes
- Shaping is fail-closed. When a successful response cannot be shaped — the body
is not valid JSON, or the converter throws — the tool answers an error instead of the raw body,
so a filter meant to hide data never leaks it by failing. A converter that names a type which is
not a concrete
IMcpToolOutputConverterkeeps the tool from being published at all. - Error responses (per
TreatErrorStatusAsToolError) and empty bodies pass through unshaped: the filters describe the success payload, not the framework's failure text. - Only JSON responses can be shaped; a shaped tool answering
text/plainor XML reports an error. - A
Toolname that matches nothing the action publishes is logged as a warning, exactly like a misspelled parameter name. - SignalR hub methods support the same attribute — the shaping applies to the hub result JSON.
How arguments are mapped
Nabu reads MVC's own binding metadata, so it maps arguments the same way your API already binds them.
| Binding source | Becomes |
|---|---|
[FromRoute] / route template token | A URL segment, URL-encoded. |
[FromQuery] | A query-string entry. Arrays repeat the key; objects use key.property. |
[FromBody] | The JSON request body. |
[FromHeader] | A request header (opt in with ExposeHeaderParameters). |
[FromServices], CancellationToken, HttpContext | Skipped — resolved by the framework. |
When no explicit attribute is present, Nabu infers the source exactly as [ApiController]
does: route tokens first, then body for complex types on POST/PUT/PATCH, then query string.
Body flattening
A single complex [FromBody] parameter is flattened into the top level of the tool
schema, so a model fills one flat object instead of a nested wrapper:
public ActionResult<TodoItem> Create([FromBody] CreateTodoRequest request)
// arguments: {"title": "...", "priority": "High", "tags": ["a"]} not {"request": {...}}
Set FlattenBodyParameter = false to keep the wrapper. Types that are not objects — a
[FromBody] int[], for example — are always sent as the whole body under their
parameter name.
Schema generation
Input schemas are generated from the CLR types and honour:
- primitives,
Guid,DateTime/DateTimeOffset/DateOnly/TimeOnly/TimeSpan,Uri,byte[] Nullable<T>and nullable reference types (string?is optional,stringis required)- collections,
string-keyed dictionaries, nested models, with cycle and depth protection [Required],[Range],[StringLength],[MinLength],[MaxLength],[RegularExpression],[EmailAddress],[Url],[DefaultValue],[Description],[Display][JsonPropertyName],[JsonIgnore]- XML documentation
<summary>on models and properties
Enums are always described to the model by name, because names are what a model can
reason about. If your API serializes enums as numbers — the default for both System.Text.Json and
Newtonsoft.Json — Nabu detects that and converts the names back to their numeric values while
building the request body, including inside nested objects and arrays. Nothing to configure;
override it with StringEnumsInRequestBody if the detection is ever wrong.