Idiomatic .NET SDK for the Universal Tool Calling Protocol (UTCP), mirroring the Python reference implementation.
UTCP (Universal Tool Calling Protocol) is an open, language-agnostic specification for discovering and invoking tools (functions, APIs, and services) in a consistent way across transports such as HTTP, MCP, CLI, text files, and sockets.
In short, UTCP provides a standardized way for AI systems and other clients to discover and call tools from different providers, regardless of the underlying protocol used (HTTP, WebSocket, CLI, etc.). This specification defines:
- Tool discovery mechanisms
- Tool call formats
- Provider configuration
- Authentication methods
- Response handling
In practice, UTCP lets you register tools from many sources, search them, call them synchronously or as streams, and compose them reliably from any runtime that implements the spec.
See the official UTCP specification for full details.
- Core models and client with async APIs and CancellationToken
- Pluggable communication protocols: HTTP (JSON + SSE), CLI, Text, MCP; Socket and GraphQL scaffolds
- OpenAPI converter to UTCP tools
- In-memory concurrent tool repository and search strategy
- Variable substitution with namespacing and env support
- Post-processing (FilterDict)
- System.Text.Json with source-gen and polymorphism
- Logging via Microsoft.Extensions.Logging
src/Utcp.Core– core types, client, search, substitution, post-processing, registrysrc/Utcp.Http– HTTP protocol (including SSE) and OpenAPI convertersrc/Utcp.Cli– CLI protocol (process runner)src/Utcp.Text– Text protocol (file read and streaming)src/Utcp.Mcp– MCP protocol (HTTP/stdio, OAuth2, SSE, result shaping)src/Utcp.Socket– TCP/UDP scaffoldssrc/Utcp.Gql– GraphQL scaffoldtests/*– xUnit tests with FluentAssertionssamples/*– client and server samples
dotnet build
dotnet testRegister a manual and call a tool:
using Utcp.Core;
using Utcp.Core.Models;
using Utcp.Http;
var client = await UtcpClientImplementation.CreateAsync(config: new UtcpClientConfig
{
ToolRepository = new Utcp.Core.Repositories.InMemToolRepository(),
ToolSearchStrategy = new Utcp.Core.Search.TagAndDescriptionWordMatchStrategy(),
ManualCallTemplates = new []
{
(CallTemplate)new HttpCallTemplate
{
CallTemplateType = "http",
Name = "openlibrary",
Method = "GET",
Url = new Uri("https://openlibrary.org/works/OL45883W.json"),
}
}
});
var result = await client.CallToolAsync("openlibrary", new Dictionary<string, object?>());
Console.WriteLine(result);Multi-server configuration with OAuth2 and HTTP SSE streaming:
using Utcp.Mcp;
using Utcp.Core.Models;
var mcpTemplate = new McpCallTemplate
{
CallTemplateType = "mcp",
Name = "mcp",
Config = new McpConfig
{
McpServers = new Dictionary<string, McpServerConfig>
{
["stdioServer"] = new() { Command = "my-mcp-server" },
["httpServer"] = new() { Url = "http://localhost:7400/mcp", Headers = new Dictionary<string,string>{{"X-Client","Utcp"}} },
}
},
Auth = new OAuth2Auth { AuthType = "oauth2", TokenUrl = "http://localhost:7400/token", ClientId = "id", ClientSecret = "secret" }
};
var register = await client.RegisterManualAsync(mcpTemplate);
var mcpResult = await client.CallToolAsync("echo", new Dictionary<string, object?>{ ["message"] = "hi" });
await foreach (var chunk in client.CallToolStreamingAsync("echo", new Dictionary<string, object?>{ ["message"] = "hi" }))
{
Console.WriteLine(chunk);
}Namespaced variables are computed by:
- Take the namespace and replace every
_with__(double underscores) - Append
_and the variable key
In other words: {namespace with underscores doubled}_{KEY}.
Examples:
variableNamespace = "manual_openlibrary",key = "API_KEY"→manual__openlibrary_API_KEYvariableNamespace = "ns_with_many_parts",key = "TOKEN"→ns__with__many__parts_TOKEN
Variables are resolved first from UtcpClientConfig.Variables using the namespaced key, then from environment variables using the same namespaced key. Both $VAR and ${VAR} forms in strings are supported.
using Utcp.Core.Substitution;
var substitutor = new DefaultVariableSubstitutor();
var cfg = new UtcpClientConfig { ToolRepository = repo, ToolSearchStrategy = search, Variables = new(){ ["manual__openlibrary_API_KEY"] = "123" } };
var substituted = substitutor.Substitute(
new Dictionary<string, object?> { ["auth"] = new Dictionary<string, object?>{ ["token"] = "${API_KEY}" } },
cfg,
"manual_openlibrary"
);You can register an OpenAPI spec by passing its URL as an HttpCallTemplate to RegisterManualAsync. The HTTP protocol automatically fetches and converts JSON OpenAPI documents into a UTCP manual at registration time.
Example (automatic conversion on register):
using Utcp.Http;
var client = await UtcpClientImplementation.CreateAsync(config: new UtcpClientConfig
{
ToolRepository = new Utcp.Core.Repositories.InMemToolRepository(),
ToolSearchStrategy = new Utcp.Core.Search.TagAndDescriptionWordMatchStrategy(),
});
await client.RegisterManualAsync(new HttpCallTemplate
{
CallTemplateType = "http",
Name = "petstore",
Url = new Uri("https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.json"),
});
var tools = await client.SearchToolsAsync("pet store", 5);End-to-end conversion using OpenApiToUtcpConverter (manual control):
using Utcp.Http.OpenApi;
using System.Net.Http;
// Load an OpenAPI document
var http = new HttpClient();
var specUrl = "https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.yaml";
var yaml = await http.GetStringAsync(specUrl);
// Convert (reader supports JSON/YAML via Microsoft.OpenApi.Readers)
var converter = new OpenApiToUtcpConverter();
var manual = converter.ConvertFromString(yaml, specUrl);
// Register the converted tools
var cfg = new UtcpClientConfig
{
ToolRepository = new Utcp.Core.Repositories.InMemToolRepository(),
ToolSearchStrategy = new Utcp.Core.Search.TagAndDescriptionWordMatchStrategy(),
ManualCallTemplates = Array.Empty<CallTemplate>()
};
var client = await UtcpClientImplementation.CreateAsync(config: cfg);
await client.RegisterManualAsync(new HttpCallTemplate { CallTemplateType = "http", Name = manual.Tools.First().ToolCallTemplate.Name, Url = new Uri("http://example") });
// Call one of the converted tools (operationId)
var tools = await client.SearchToolsAsync("pet store", 5);
var anyTool = tools.First();
var output = await client.CallToolAsync(anyTool.Name, new Dictionary<string, object?>());
Console.WriteLine(output);See also tests/Utcp.Http.Tests/OpenApiConverterTests.cs for focused examples.
samples/ClientSample– demonstrates setup, HTTP + MCP configurationsamples/ServerSample– minimal ASP.NET API with UTCP discovery endpoint
Run a local command and capture stdout/stderr:
using Utcp.Cli;
var client = await UtcpClientImplementation.CreateAsync(config: new UtcpClientConfig
{
ToolRepository = new Utcp.Core.Repositories.InMemToolRepository(),
ToolSearchStrategy = new Utcp.Core.Search.TagAndDescriptionWordMatchStrategy(),
});
// Register a CLI tool (e.g., echo)
var template = new CliCallTemplate { CallTemplateType = "cli", Name = "local", Command = "/bin/echo" };
await client.RegisterManualAsync(template);
// Call it
var resp = await client.CallToolAsync("local", new Dictionary<string, object?> { ["args"] = new [] { "hello" } });
Console.WriteLine(resp); // expected: "hello\n"Read a file fully and as a stream of chunks:
using Utcp.Text;
var text = new TextCallTemplate { CallTemplateType = "text", Name = "docs", FilePath = "README.md" };
await client.RegisterManualAsync(text);
var all = await client.CallToolAsync("docs", new Dictionary<string, object?>());
Console.WriteLine(((string)all!).Length > 0); // expected: True
var streamed = client.CallToolStreamingAsync("docs", new Dictionary<string, object?>());
await foreach (var chunk in streamed)
{
Console.WriteLine(chunk); // expected: lines (or chunks if ChunkSizeBytes set)
}Echo via MCP HTTP and stdio with result shaping:
using Utcp.Mcp;
var mcp = new McpCallTemplate
{
CallTemplateType = "mcp",
Name = "mcp",
Config = new McpConfig
{
McpServers = new Dictionary<string, McpServerConfig>
{
["http"] = new() { Url = "http://localhost:7400/mcp" },
["stdio"] = new() { Command = "my-mcp-server" },
}
}
};
await client.RegisterManualAsync(mcp);
var r = await client.CallToolAsync("echo", new Dictionary<string, object?> { ["message"] = "test" });
Console.WriteLine(r); // expected: { reply = "you said: test" } or similar shaped object/string
await foreach (var s in client.CallToolStreamingAsync("echo", new Dictionary<string, object?> { ["message"] = "test" }))
{
Console.WriteLine(s); // expected: streamed chunks/events if server uses SSE
}Protocols are registered in Utcp.Core.Protocols.ProtocolRegistry. Custom protocols can be added at runtime:
using Utcp.Core.Protocols;
ProtocolRegistry.Register("my-protocol", new MyProtocol());Use post-processors to shape results. A simple filter example:
using Utcp.Core.PostProcessing;
var cfg = new UtcpClientConfig { ToolRepository = repo, ToolSearchStrategy = search, PostProcessing = new [] { new FilterDictPostProcessor(new []{"allowed"}) } };Default search TagAndDescriptionWordMatchStrategy ranks tools by tag hits and description term overlap:
var results = await client.SearchToolsAsync("weather forecast", limit: 5);InMemToolRepository uses async synchronization to support concurrent reads/writes safely. All client operations are async and accept CancellationToken.
- Rich argument and state validation with specific exceptions
- HTTP paths use
EnsureSuccessStatusCodeand propagate details - MCP stdio paths surface process start and JSON parsing errors
- Data models map 1:1 (Auth, CallTemplate, Tool, UtcpManual, UtcpClientConfig, RegisterManualResult)
- MCP mirrors registration, calling, streaming, OAuth2 token flows, and result shaping
- OpenAPI converter maps security schemes and schemas similarly; see tests for parity
- Variable substitution supports namespacing and env resolution
- GitHub Actions builds/tests/packs on Windows, Linux, macOS (
.github/workflows/build.yml) - NuGet metadata via
Directory.Build.props(packages per project)
This project is licensed under the Mozilla Public License 2.0 (MPL-2.0). See LICENSE for details.