net10.0 and net11.0
TimeWarp.Contracts
You write the request once. The web host, the CLI, and the MCP host each read that same class and expose it in their own way. --capabilities, OpenAPI, and tools/list are readings of OperationCatalog. You do not keep a second description of the operation for each host.
request class
[Operation("roles.delete")] · IIdempotentCommand<OneOf<Response, SharedProblemDetails>>
HTTP
ApiRoute
PUT api/Roles/{RoleId}
CLI
Pattern
roles.delete
MCP
ToolName
roles.delete
The package builds from source/foundation/ in TimeWarp.Architecture and versions with that repo. It depends on TimeWarp.Mediator.Contracts and OneOf. It moves to its own repository before a stable Nuru release takes the dependency. What follows is the package readme and skills/tw-web-api-contracts.
on the request class
[Operation]
Put [Operation] on the nested Query or Command. The constructor argument is the operation name, for example roles.get. The hosts share that name. It is not the HTTP route, the Nuru pattern, or the MCP tool name. A blank name throws.
- name
- The name every host shares. Lower case, resource.action: roles.list, roles.get, roles.create, roles.update, roles.delete, roles.set-permissions.
- Description
- The summary shown in --capabilities, OpenAPI, and tools/list.
- Group
- Optional. Split segments with '.' or a space. Blank means ungrouped. A host may copy it to a CLI groupPath or use it to filter tools. It does not get prepended to the route or the command. roles.get stays roles.get.
- Title
- Optional display name. MCP copies it to title. Leave it off when you do not have a separate label. It is not another copy of the operation name, and it is not Description.
Kind is not a parameter of [Operation]. The mediator interface on the request is the kind.
roles slice and the package readme
Samples
roles.get, roles.create, roles.update, and roles.delete follow the roles slice. notes.append is the example in the package readme. symbols.search is a query whose [ApiRoute] is POST.
The CLI pattern and the MCP tool name are filled in with the operation name. That is only so the binding fields are visible. Nuru writes the real pattern and the real tool name. A line through a transport means that host does not expose the operation.
This is the delete from the contracts skill. Title, Destructive, and OpenWorld(false) are written on the command. Group is admin.roles. The route and the command name do not start with that group.
{
"name": "roles.delete",
"kind": "IdempotentCommand",
"members": [
{
"name": "RoleId",
"type": "Guid",
"required": true
}
],
"returnType": "DeleteRole.Response",
"description": "Delete a role.",
"group": "admin.roles",
"title": "Delete role",
"destructiveHint": true,
"openWorldHint": false,
"bindings": {
"http": {
"route": "api/Roles/{RoleId:guid}",
"verb": "Delete"
},
"cli": {
"pattern": "roles.delete"
},
"mcp": {
"toolName": "roles.delete"
}
}
}TimeWarp.Mediator
IQuery, ICommand, IIdempotentCommand
The generator reads the interface. It tests IIdempotentCommand before ICommand, because the idempotent interfaces extend the command interfaces. A request that implements none of them is left out of the catalog. Handlers are still IRequestHandler. Picking a kind does not change dispatch.
| When | Interface | What the kind sets |
|---|---|---|
| The call only reads. Calling it again changes nothing. | IQuery<TResponse> | readOnlyHint true |
| The call writes, and calling it again writes again. Create, append, charge. | ICommand or ICommand<TResponse> | readOnlyHint false, idempotentHint false |
| The call writes, and calling it again leaves the same state. Replace, delete. | IIdempotentCommand or IIdempotentCommand<TResponse> | idempotentHint true |
IIdempotentCommand means you are willing to say a retry is safe. It does not add an idempotency key, and it does not deduplicate. If you are not sure, use ICommand.
The HTTP verb is the verb on [ApiRoute]. In the template, GET usually goes with a query, POST with a command, and PUT or DELETE with an idempotent command. The generator does not enforce that. A search whose body is posted can still be an IQuery. A PUT whose handler appends history is an ICommand.
If none of the three fits, use IRequest and write why in the Design region.
OperationMember
Where members come from
The generators fill OperationMember from the property as you declared it. This package does not reflect over the request.
Name is the property name. Required is true when the property has required. Otherwise it is true when the type is non-nullable and there is no initializer. T? is optional unless you also wrote required, and required wins over both a nullable annotation and an initializer.
DefaultValue is the initializer, copied as source text. No initializer means the field is omitted. The generator will not insert 0, false, null, or default(T).
If the member type is an enum, AllowedValues is the named constants in declaration order. Description is [Description]. XML comments are not copied here. The HTTP host still uses the request's XML docs for OpenAPI.
string, no initializer, no required keyword. The type is non-nullable, so Required is true. DefaultValue is left off.
{
"name": "Name",
"type": "string",
"required": true
}tool hints
Destructive and OpenWorld
readOnlyHint and idempotentHint come from the kind, as in the table above. You write the other three yourself: title, whether the call is destructive, and whether it reaches outside the app.
Destructive and open-world are separate attributes on purpose. A missing named bool on [Operation] would show up as false under reflection, and you could not tell "I said false" from "I did not say". No attribute means the hint was not set. [Destructive] and [Destructive(true)] are the same claim.[Destructive(false)] means the call only adds data. [OpenWorld(false)] means it only touches local state.
MCP clients treat a missing destructiveHint as true, and they only look at it when the tool is not read-only. A missing openWorldHint is also treated as true. If you leave both attributes off a command, clients assume the call can delete or overwrite, and that it can reach outside the app. Write the attributes when that assumption is wrong.
Put the attributes on a request that already has [Operation] with a real name. Without that name, the contracts generator skips them. Reflection still sees them. They are stored on OperationDescriptor, not on McpOperationBinding, so a CLI catalog can show them when there is no MCP tool. They do not change the tool name. destructiveHint is not written for an IQuery.
Generators match TimeWarp.Contracts.DestructiveAttribute and TimeWarp.Contracts.OpenWorldAttribute by metadata name. The constructor parameters are isDestructive and isOpenWorld.
Nuru has its own [Destructive] and [OpenWorld], same constructors. A file that imports both namespaces gets CS0104 on the bare name. Keep the contracts attributes on the contract. Where both usings are in scope, write [TimeWarp.Contracts.Destructive].
The switches below build the annotations object a host would put on tools/list.
Kind
Destructive
OpenWorld
- No Destructive attribute. An MCP client treats the missing hint as true.
- No OpenWorld attribute. An MCP client treats the missing hint as true, so it assumes the call can reach outside the app.
{
"annotations": {
"readOnlyHint": false,
"idempotentHint": false
}
}what the hosts read
OperationCatalog
OperationCatalog names the app or the tool. Each OperationDescriptor is one operation: name, description, group, title, the two nullable hints, kind, members, return type, and bindings.
ReturnType is the display name of the success type. When the request returns OneOf<TSuccess, SharedProblemDetails>, the catalog stores TSuccess. The problem type is the same on every operation, so it is not copied onto each entry.
HTTP
HttpOperationBinding is the route template and the verb as text: Get, Post, Put, Delete. Text, so this package does not take a dependency on the foundation HTTP enum. [ApiRoute] writes the binding. The FastEndpoint generator serves the route and does not read the hints. The template's HTTP host does not publish a catalog. It puts OperationName, OperationDescription, and OperationGroup on the generated partial.
CLI
CliOperationBinding.Pattern is the Nuru route. The host copies Group to its own groupPath. It does not prefix Pattern with the group. The command name is the operation name. Nuru builds the route from the contract and dispatches through the mediator.
MCP
McpOperationBinding.ToolName is the name in tools/list. Title and the hints stay on the descriptor. A null MCP binding means there is no tool. The hints are still on the descriptor if a CLI catalog wants them.
A null binding means that host does not expose the operation. Camel case, and whether nulls are written, is up to the host that serializes. The JSON in the samples leaves nulls out, so a hint that was not set is missing rather than false.
IResponseRenderer<TResponse> turns a success value into text for the CLI or MCP host. If none is registered, those hosts write JSON. HTTP ignores the renderer. The renderer returns text. It does not pick a content type.
TimeWarp.Foundation.Types
SharedProblemDetails
The failure side of the OneOf is SharedProblemDetails, RFC 7807. It lives in this assembly so the CLI and MCP hosts can use it without referencing ASP.NET. The namespace stays TimeWarp.Foundation.Types. TimeWarp.Foundation.Contracts type-forwards to it, so assemblies that already compiled against the foundation package keep resolving the same type. Defining it again would be a different type, and the OneOf arms would no longer match.
The shape copies ProblemDetails: property order, nulls omitted, and an extension bag. A payload written by the server's ProblemDetails deserializes here. Validation errors land in Extensions. The contracts assembly is also used by the WASM client, which is why it cannot reference ASP.NET Core.
web/features
Adding a contract in the template
- One
public static partial classper operation, next to its handler. Feature folders are plural. The file iscreate-role-contracts.cs. Don't share an entity DTO across endpoints. If Blazor edits the payload, share those fields through anI*Detailsinterface. [ApiEndpoint]on the outer class when a server should generate the FastEndpoint. Then exactly one of[EndpointAuthorize]or[EndpointAllowAnonymous(reason)]. TWA0013 if the marker is missing. TWA0014 if both are present, or if the endpoint is anonymous and the request also carriesIAuthApiRequest.[ApiRoute]and[Operation("resource.action", Description = "…")]on the nested request. TWA0035 suggests[Operation]on a hosted contract that does not have one. The build still passes.- Return
OneOf<Response, SharedProblemDetails>throughIQuery,ICommand, orIIdempotentCommand. The mediator is TimeWarp.Mediator. - Validate
I*Detailswith oneAbstractValidator, and compose that validator into the endpoint validator. Validation runs inFluentValidationBehavior. Don't validate again in the handler. - Write
[Destructive]and[OpenWorld]when the client default would be wrong. Leaving them off a command means MCP clients assume both are true.
IAuthApiRequest puts a UserId on the request so mock mode, where there is no server, can vary the response. It does not authenticate the call. [EndpointAuthorize] is what makes the generated endpoint require auth. The server gets the user from the token. Don't trust the UserId the client sent.