What is UTCP?
UTCP (Universal Tool Calling Protocol) lets AI agents discover tools through a manual and then call them directly over their native protocol, with no wrapper server in between. Utcp.AspNetCore publishes a UTCP v1.1 manual at GET /utcp, built automatically from your app's ASP.NET Core OpenAPI document, so agents can discover and call your HTTP endpoints directly.
Quickstart
1. Install
dotnet add package Utcp.AspNetCore
2. Wire it up
using Utcp.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi(); // required: Microsoft.AspNetCore.OpenApi
builder.Services.AddUtcp(); // optionally: AddUtcp(o => o.DocumentName = "v2"); returns a UtcpBuilder
var app = builder.Build();
app.MapUtcp("/utcp"); // returns an IEndpointConventionBuilder; chain .AllowAnonymous() etc.
app.Run();
Set an operationId on each endpoint (for example .WithName("GetWeather")). Operations without one are skipped. The /utcp endpoint is excluded from the OpenAPI document.
3. Fetch the manual
curl -s http://localhost:5080/utcp
Trimmed example response:
{
"manual_version": "1.0.0",
"utcp_version": "1.0.1",
"tools": [
{
"name": "GetWeather",
"description": "Get a multi-day weather forecast for a city",
"inputs": {
"type": "object",
"properties": {
"city": { "type": "string" },
"days": { "type": "integer" }
},
"required": ["city"]
},
"tool_call_template": {
"call_template_type": "http",
"http_method": "GET",
"url": "http://localhost:5080/weather/{city}"
}
},
{
"name": "GetCurrentUser",
"description": "Get the current authenticated user",
"tool_call_template": {
"call_template_type": "http",
"http_method": "GET",
"url": "http://localhost:5080/me",
"auth": {
"auth_type": "api_key",
"api_key": "Bearer ${BEARER_TOKEN}",
"var_name": "Authorization",
"location": "header"
}
}
}
]
}
The repo includes a sample with eight tools — see Run the sample.
Example: endpoint → tool
A decorated minimal API endpoint:
app.MapGet("/weather/{city}", (string city, int? days) =>
TypedResults.Ok(GetForecasts(city, days ?? 3)))
.WithName("GetWeather") // → tool name
.WithSummary("Get a multi-day weather forecast for a city") // → description
.WithTags("Weather"); // → tags
record WeatherForecast(string City, DateOnly Date, int TemperatureC, string Summary);
becomes this tool in the /utcp manual (actual output):
{
"name": "GetWeather",
"description": "Get a multi-day weather forecast for a city",
"tags": ["Weather"],
"inputs": {
"type": "object",
"properties": {
"city": { "type": "string" },
"days": { "type": "integer", "format": "int32" }
},
"required": ["city"]
},
"outputs": {
"type": "array",
"items": {
"required": ["city", "date", "temperatureC", "summary"],
"type": "object",
"properties": {
"city": { "type": "string" },
"date": { "type": "string", "format": "date" },
"temperatureC": { "type": "integer", "format": "int32" },
"summary": { "type": "string" }
}
}
},
"tool_call_template": {
"name": "GetWeather",
"call_template_type": "http",
"url": "http://localhost:5080/weather/{city}",
"http_method": "GET",
"content_type": "application/json"
}
}
The route's {city} stays a placeholder in the tool URL, the optional days query parameter becomes an optional input, and the WeatherForecast[] response type becomes the outputs schema. Add .RequireAuthorization() (with a matching OpenAPI security scheme) and the tool gains an auth block — see Auth schemes.
MVC controllers work too
Attribute-decorated MVC controllers map the same way. Call AddControllers() and MapControllers(), and the OpenAPI document (and so the manual) picks the actions up:
[ApiController]
[Route("products")]
public class ProductsController : ControllerBase
{
[HttpGet("{id:int}", Name = "GetProduct")] // Name → operationId → tool name
[EndpointSummary("Get a product by id")] // → description
[Tags("Products")] // → tags
[ProducesResponseType<Product>(StatusCodes.Status200OK)] // → outputs
public IActionResult GetProduct(int id, [FromQuery] bool includePricing = false) =>
Ok(new Product(id, $"Product {id}", includePricing ? 19.99m : null));
}
public record Product(int Id, string Name, decimal? Price);
becomes this tool (trimmed to name, description, tags, inputs and tool_call_template; actual output otherwise):
{
"name": "GetProduct",
"description": "Get a product by id",
"tags": ["Products"],
"inputs": {
"type": "object",
"properties": {
"id": { "type": "integer", "format": "int32" },
"includePricing": { "type": "boolean", "default": false }
},
"required": ["id"]
},
"tool_call_template": {
"name": "GetProduct",
"call_template_type": "http",
"url": "http://localhost:5080/products/{id}",
"http_method": "GET",
"content_type": "application/json"
}
}
[EndpointName("GetProduct")] works as an alternative to Name = on the route attribute. The outputs schema (the Product record) is omitted from the JSON above for brevity.
OpenAPI → UTCP mapping
| OpenAPI | UTCP |
|---|---|
operationId | tool name (operations without one are skipped) |
| summary / description | tool description |
| operation tags | tool tags |
| path / query / header parameters | inputs.properties (required honored; path params always required) |
| JSON request body | inputs.properties.body, with tool_call_template.body_field = "body" and content_type set to the media type |
| header parameters | tool_call_template.header_fields |
200 response (else first 2xx) JSON schema | outputs |
| path + base URL | tool_call_template.url, keeping {param} placeholders; call_template_type is http |
| security scheme | auth (see Auth schemes) |
$ref component schemas are inlined so each tool is self-contained.
Auth schemes
Security schemes are read from the operation, else the document; first match wins. <SCHEMEID> is the scheme's component id, uppercased with non-alphanumerics replaced by _ (id Bearer → BEARER_TOKEN). Agents substitute the placeholders; the manual never contains real secrets.
HTTP bearer
{ "auth_type": "api_key", "api_key": "Bearer ${<SCHEMEID>_TOKEN}", "var_name": "Authorization", "location": "header" }
API key in a header
{ "auth_type": "api_key", "api_key": "${<SCHEMEID>_API_KEY}", "var_name": "<scheme Name>", "location": "header" }
API key in the query string
{ "auth_type": "api_key", "api_key": "${<SCHEMEID>_API_KEY}", "var_name": "<scheme Name>", "location": "query" }
Query strings commonly end up in access logs and proxies. Prefer header-based keys in real APIs.
HTTP basic
{ "auth_type": "basic", "username": "${<SCHEMEID>_USERNAME}", "password": "${<SCHEMEID>_PASSWORD}" }
OAuth2 and other schemes are ignored.
Custom tools and post-processing
AddUtcp() returns a UtcpBuilder. Chain extra service registrations through its .Services property, implement IUtcpToolSource to add tools that are not in your OpenAPI document, and use Configure to post-process the final manual.
builder.Services.AddUtcp()
.Register<MyToolSource>() // or .Register(instance)
.Configure(ctx => ctx.Remove("GetAdminStats")) // ctx.Add / Remove / Replace / Manual
.Configure(async (ctx, ct) => { /* async hook */ });
Tool sources
.Register<TSource>() or .Register(instance) adds an IUtcpToolSource. Its async GetToolsAsync(UtcpToolSourceContext ctx, CancellationToken ct) returns the tools to add; the context exposes BaseUrl, Options and Services. Register as many sources as you like; registering the same type twice is a no-op.
public sealed class SampleToolSource : IUtcpToolSource
{
public ValueTask<IReadOnlyList<UtcpTool>> GetToolsAsync(UtcpToolSourceContext context,
CancellationToken cancellationToken = default)
{
IReadOnlyList<UtcpTool> tools =
[
new UtcpTool
{
Name = "GetStatus",
Description = "Get service status (custom tool source)",
ToolCallTemplate = new HttpCallTemplate
{
Name = "GetStatus", Url = context.BaseUrl + "/status", HttpMethod = "GET"
}
}
];
return ValueTask.FromResult(tools);
}
}
Post-processing hooks
.Configure(ctx => ...) and .Configure(async (ctx, ct) => ...) run against a UtcpManualContext after all tools are merged:
Add(tool): adds a tool; throws if the name already exists.Remove(name): removes a tool and returnsbool(whether one was removed).Replace(tool): replaces a tool by name; throws if it is missing.Manual: raw access to the manual, e.g.ctx.Manual.Tools.Single(t => t.Name == "GetStatus").Tags.Add("Custom").
Merge order
OpenAPI tools first, then tool sources (registration order), then Configure hooks (registration order). Duplicate tool names throw InvalidOperationException.
Run the sample
dotnet run --project samples/Utcp.Sample.Api
curl -s http://localhost:5080/utcp
The sample exposes eight tools — seven OpenAPI endpoints plus a custom GetStatus tool from a tool source (see Custom tools):
GET /weather/{city}?days=3(GetWeather): path and query inputs.POST /orders(CreateOrder): JSON body.GET /me(GetCurrentUser): requires JWT bearer auth, so its tool includes theauthblock.GET /admin/stats(GetAdminStats): API key in theX-Api-Keyheader (schemeApiKeyHeader).GET /reports(GetReports): API key in theapi_keyquery parameter (schemeApiKeyQuery).GET /products/{id}(GetProduct): MVC controller action, path and query inputs.GET /legacy/export(GetLegacyExport): HTTP Basic (schemeBasic).
Demo credentials (sample only, bound from the DemoAuth configuration section): appsettings.json ships empty values and the handlers fail closed — secured endpoints reject everything until credentials are configured. appsettings.Development.json supplies dev-only values (API key demo-key; basic demo / demo), so they only work when ASPNETCORE_ENVIRONMENT=Development (the default for dotnet run). For anything else, supply your own via user-secrets or environment variables (e.g. DemoAuth__ApiKey).
curl -H "X-Api-Key: demo-key" http://localhost:5080/admin/stats
Tests
dotnet test
The tests call the live /utcp endpoint and validate it against a JSON Schema (tests/Utcp.AspNetCore.Tests/Schemas/utcp-manual.schema.json).
Options
Configure with AddUtcp(o => ...) (UtcpOptions).
| Option | Default | Description |
|---|---|---|
DocumentName | "v1" | Name of the OpenAPI document to convert. |
ManualVersion | "1.0.0" | Value of manual_version in the manual. |
Securing /utcp & security notes
/utcpis anonymous by default and lists all documented operations, including secured ones. If your API surface is sensitive, protect it:app.MapUtcp("/utcp").RequireAuthorization();- The manual never contains real tokens. Secured tools emit placeholders such as
Bearer ${BEARER_TOKEN}for the agent to substitute. - Tool URLs use the OpenAPI document's
servers[0].Urlwhen absolute; otherwise they reflect the request'sHostheader. Behind proxies or on the open internet, add a server entry via a document transformer that setsdocument.Servers, so an attacker-controlled Host header can't be reflected into the manual. - The sample's
AddJwtBearer()has no authority/key configuration and is not production-ready; it exists only to demonstrate the security-scheme → UTCP auth mapping.
Limitations
- HTTP call templates only.
- Only
httpbearer/basic andapiKeyschemes are mapped. OAuth2 and other schemes are ignored. - Operations need an
operationId, or they are skipped. - Targets .NET 9 and OpenAPI 3.0 documents from
Microsoft.AspNetCore.OpenApi.