utcp 0.3.0
utcp: ^0.3.0 copied to clipboard
LLM tool calling for Dart and Flutter: a UTCP client that calls tools over HTTP, SSE and OpenAPI directly, with no wrapper server.
utcp #
A Dart client for the Universal Tool Calling Protocol (UTCP).
UTCP lets an agent call tools directly through their native interfaces. A provider publishes a manual: a JSON or YAML document that lists its tools, their input/output JSON Schemas, and how to call each one. The client fetches the manual, then calls the tool's own endpoint. There is no wrapper server in between.
Usage #
import 'package:utcp/utcp.dart';
Future<void> main() async {
final client = await UtcpClient.create(
config: UtcpClientConfig(
variables: {'weather_API_KEY': 'my-key'},
manualCallTemplates: [
HttpCallTemplate(name: 'weather', url: 'https://api.example.com/utcp'),
],
),
);
final tools = await client.searchTools('current weather');
final result = await client.callTool(tools.first.name, {'city': 'Seoul'});
print(result);
await client.close();
}
Tool names are prefixed with their manual name (weather.get_weather). Tool.inputs holds the
JSON Schema you hand to an LLM as the tool definition.
A runnable end-to-end example against a local server is in example/utcp_example.dart.
Configuration files #
UtcpClient.fromFile and UtcpClientConfig.fromFile read the configuration format shared with
the Python and TypeScript SDKs from a JSON file, or a YAML file when it ends in .yaml or .yml.
UtcpClientConfig.toJson writes it back. They need dart:io, so not in a browser.
final client = await UtcpClient.fromFile(
'utcp_config.yaml',
protocols: [FileCommunicationProtocol()], // from utcp_file
rootDir: '/srv/tools', // resolves relative file paths; the current directory when unset
);
The client parses call templates with the parsers of its own protocols first. A protocol that
serves a type CallTemplate.registry does not know yet adds it there, but never replaces a parser
already registered, so one client's protocols do not change how other clients parse.
Manuals created by this package declare utcp_version utcpProtocolVersion (1.1.0), the spec
version it implements.
Flutter #
The core works in Flutter apps on mobile, desktop and web, and has no Flutter dependency. Create the
client once, for example in initState or a provider, and close it in dispose:
class _ToolsState extends State<Tools> {
late final Future<UtcpClient> _client = UtcpClient.create(
config: UtcpClientConfig(
manualCallTemplates: [
HttpCallTemplate(name: 'weather', url: 'https://api.example.com/utcp'),
],
),
);
@override
void dispose() {
_client.then((client) => client.close());
super.dispose();
}
}
utcp_websocket also runs on the web, and so does utcp_mcp with streamable HTTP servers.
utcp_cli, utcp_file and utcp_socket need dart:io: use them on mobile and desktop, not in a
browser.
LLM tool calling #
Each Tool carries a name, a description and a JSON Schema for its inputs, which is what LLM
function-calling APIs take. Hand the tools to the model, then run each call it makes through
callTool:
final tools = [
for (final tool in await client.getTools())
{
// Most LLM APIs allow only [a-zA-Z0-9_-] in tool names.
'name': tool.name.replaceAll('.', '__'),
'description': tool.description,
'input_schema': tool.inputs, // `parameters` for OpenAI and Gemini
},
];
// When the model asks for a tool, call it and send the result back:
final result = await client.callTool(utcpName, toolInput);
example/claude_tool_use.dart runs the full loop against the
Claude Messages API: tool definitions, parallel tool calls, errors returned to the model, and
the final answer.
Variables #
Call templates can reference ${VAR} or $VAR. Values are resolved from
UtcpClientConfig.variables, then any VariableLoaders, then the process environment.
Inside a manual, variables are namespaced by the manual name, with underscores in the name doubled:
${API_KEY} in manual my_api resolves my__api_API_KEY. This matches the Python and TypeScript SDKs.
Manual names keep Unicode letters and numbers, as in Python (café stays café; TypeScript would
make it caf_), and every other character becomes _.
A string containing a JSON Schema $ref is left as it is; $refresh_token and other variables
that merely start with ref are still replaced, as in Python.
HTTP call templates #
Tool arguments are mapped onto the request in this order:
header_fieldsbecome request headers.body_field(defaultbody) becomes the request body.{name}placeholders inurlbecome path segments.- Any remaining arguments become query parameters.
Supported auth types are api_key (header, query, or cookie), basic, oauth2 (client
credentials, with token caching), and oauth2_user (a pre-provisioned bearer token).
oauth2_user comes from the TypeScript SDK: the spec and the Python SDK do not define it, so a
config that uses it does not load in Python.
Tool search #
searchTools uses the tag_and_description_word_match strategy with the TypeScript SDK's
scoring, which also matches the tool name and partial words. The spec's search page describes the
Python SDK's simpler scoring, so the SDKs can rank tools differently. As in TypeScript, a limit
of 0 returns every tool; Python returns none.
Custom strategies and tool repositories register with ToolSearchStrategy.registry.register and
ToolRepository.registry.register, keyed by tool_search_strategy_type and
tool_repository_type.
Streaming transports #
sse and streamable_http map arguments and auth the same way. Use callToolStreaming to get
results as they arrive; callTool collects them. Cancelling the stream subscription closes the
HTTP response.
sseyields thedataof each Server-Sent Event, JSON-decoded when it parses.event_typekeeps only events of that type (an event withoutevent:ismessage). A dropped stream is reopened withLast-Event-ID, waitingretry_timeoutor the server'sretry:(capped at 60 s), up to 5 times per call; calls that send a body are never re-issued. HTTP errors and responses that are nottext/event-streamfail at once.streamable_httpdecodes the response by Content-Type:application/x-ndjsonyields one value per line,application/jsonone value for the whole body, anything elseUint8Listchunks ofchunk_sizebytes, the last one possibly shorter.timeoutbounds the whole call, including reading the stream.
Post-processing #
UtcpClientConfig.postProcessing (post_processing in JSON) runs in order on every tool result
and every streamed chunk:
"post_processing": [
{"tool_post_processor_type": "filter_dict", "exclude_keys": ["internal_id"]},
{"tool_post_processor_type": "limit_strings", "limit": 2000, "only_include_manuals": ["docs"]}
]
filter_dict removes keys (exclude_keys) or keeps only some (only_include_keys) at any depth.
limit_strings truncates every string to limit code points (default 10000). Both take
exclude_tools, only_include_tools, exclude_manuals and only_include_manuals. Custom
processors register with ToolPostProcessor.registry.register.
OpenAPI #
An http manual URL may serve an OpenAPI 2.0 (Swagger) or 3.x document, in JSON or YAML, instead of
a UTCP manual. Each operation with an operationId becomes a tool of that name: header parameters
become header_fields, the request body becomes the body argument, and path parameters stay
{name} placeholders. Local $refs are inlined. Relative server and OAuth2 token URLs resolve against
the URL the document was fetched from.
Security schemes become auth with variable placeholders such as ${API_KEY_0}, resolved as
petstore_API_KEY_0 in manual petstore (apiKey, HTTP basic, HTTP bearer, and OAuth2 flows
with a token URL). Set auth_tools on the manual call
template to supply the credentials instead; it replaces generated auth of the same type and, for API
keys, the same name and location. The manual's static headers are sent only by generated tools on the spec's own origin, since they often carry credentials.
To convert a document you loaded yourself, use OpenApiConverter(spec, specUrl: ...).convert().
Text call templates #
A text call template carries a UTCP manual or OpenAPI document inline in content, as JSON or YAML,
so it works on every platform. base_url replaces an OpenAPI document's servers. The tools converted
from OpenAPI use http, so list it in allowed_communication_protocols. Inline OpenAPI documents are
treated as remote: their tools may target loopback only through base_url. Calling a tool whose call
template is text returns its content.
Security #
- Remote URLs must use HTTPS. Plain HTTP is allowed only to
localhost,127.0.0.1, or::1. - A manual or OpenAPI document fetched from a remote origin may not point tools or token URLs at loopback addresses. Tool arguments cannot move a request onto loopback.
- Every redirect hop is re-validated, and auth headers are dropped when a redirect changes origin.
- A manual's tools may only use the manual's own transport unless
allowed_communication_protocolssays otherwise.
Extending #
A transport is a CommunicationProtocol that lists the call template types it serves. Pass it to the
client, which registers its parsers:
final client = await UtcpClient.create(protocols: [MyProtocol()]);
Custom auth types register with Auth.registry.register.
Packages #
| Package | Transports |
|---|---|
utcp |
Core client, http (with OpenAPI conversion), sse, streamable_http, text |
utcp_mcp |
mcp (MCP servers over stdio, streamable HTTP and legacy HTTP+SSE) |
utcp_cli |
cli |
utcp_file |
file, .env variable loader |
utcp_socket |
tcp, udp |
utcp_websocket |
websocket |
License #
MPL-2.0, the same license as the reference UTCP SDKs this package is ported from.