Utcp.AspNetCore icon

Utcp.AspNetCore

Publish a UTCP manual for AI agents straight from your ASP.NET Core OpenAPI document.

dotnet add package Utcp.AspNetCore

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

OpenAPIUTCP
operationIdtool name (operations without one are skipped)
summary / descriptiontool description
operation tagstool tags
path / query / header parametersinputs.properties (required honored; path params always required)
JSON request bodyinputs.properties.body, with tool_call_template.body_field = "body" and content_type set to the media type
header parameterstool_call_template.header_fields
200 response (else first 2xx) JSON schemaoutputs
path + base URLtool_call_template.url, keeping {param} placeholders; call_template_type is http
security schemeauth (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:

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):

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).

OptionDefaultDescription
DocumentName"v1"Name of the OpenAPI document to convert.
ManualVersion"1.0.0"Value of manual_version in the manual.

Securing /utcp & security notes

Limitations