Tools Documentation
July 30, 2026 · View on GitHub
Complete guide to implementing MCP tools.
What are Tools?
Tools are functions that AI can call to perform actions. They are the primary way for clients to interact with server capabilities.
Basic Tool Registration
server.registerTool(
'tool-name',
description: 'What the tool does',
inputSchema: JsonSchema.object(
properties: {
'param': JsonSchema.string(),
},
),
callback: (args, extra) async {
// Process request
return CallToolResult(
content: [TextContent(text: 'result')],
);
},
);
JSON Schema Validation
The built-in validator defaults to JSON Schema Draft 2020-12 and accepts an explicitly declared Draft 7 schema for MCP 2025-11-25 compatibility. It validates schema structure and instances, including same-document fragment references, conditionals, tuple/array constraints, and unevaluated items and properties.
Local fragments and absolute or relative resource identifiers that resolve
inside the supplied schema document are evaluated synchronously, including
$dynamicRef. For predictable offline behavior, unresolved references outside
that document and unsupported dialects are rejected without network I/O.
Custom vocabularies are preserved but not interpreted. Schema compilation is
bounded to a depth of 64 and 1,024 subschemas. Keep external reference
resolution and custom-vocabulary processing in application code when needed.
MCP 2025-11-25 requires inputSchema; outputSchema remains optional. Its
structured tool values are object-rooted, so initialization-era registrations use
JsonSchema.object(...) or JsonObject.fromJson(...) at both schema roots and
put primitive values under named properties. Primitive tool input schemas and
primitive form-elicitation roots are rejected at the MCP wire boundary.
When a client negotiates MCP 2025-11-25 or an earlier initialization-era
version, it ignores non-object output schemas and does not retain non-object
structuredContent; the result's compatibility content remains available.
MCP 2026-07-28 clients validate and retain structured output with any JSON
root.
The Dart API reflects that spec difference while preserving the 2.2.2
surface. ToolInputSchema and the existing registerTool(outputSchema:)
parameter are object-root compatibility types. Tool.inputSchema remains
JsonSchema-typed so existing schema-parsing code still compiles, but parsing
and serialization enforce its object root. Tool.outputSchema is broader
because MCP 2026-07-28 permits any root; register those tools with
registerStatelessTool(outputJsonSchema:) and inspect or update them through
the returned RegisteredStatelessTool. This asymmetry is therefore intentional
at the API boundary, not a difference in wire-field naming.
Basic Types
// String
'param': JsonSchema.string(
description: 'A text parameter',
)
// Number
'count': JsonSchema.number(
description: 'A numeric value',
)
// Integer
'age': JsonSchema.integer(
minimum: 0,
maximum: 150,
)
// Boolean
'enabled': JsonSchema.boolean(
description: 'Enable feature',
)
// Array
'tags': JsonSchema.array(
items: JsonSchema.string(),
minItems: 1,
maxItems: 10,
)
// Object
'config': JsonSchema.object(
properties: {
'key': JsonSchema.string(),
'value': JsonSchema.number(),
},
)
Advanced Validation
server.registerTool(
'create-user',
inputSchema: JsonSchema.object(
properties: {
'username': JsonSchema.string(
minLength: 3,
maxLength: 20,
pattern: r'^[a-zA-Z0-9_]+$',
),
'email': JsonSchema.string(format: 'email'),
'age': JsonSchema.integer(minimum: 13),
'role': JsonSchema.string(
enumValues: ['user', 'admin', 'moderator'],
),
'preferences': JsonSchema.object(
properties: {
'notifications': JsonSchema.boolean(),
'theme': JsonSchema.string(
enumValues: ['light', 'dark'],
defaultValue: 'light',
),
},
),
},
required: ['username', 'email'],
),
callback: (args, extra) async {
final username = args['username'] as String;
final email = args['email'] as String;
final age = args['age'] as int?;
final role = args['role'] as String? ?? 'user';
// Create user...
return CallToolResult(
content: [TextContent(text: 'User created: $username')],
);
},
);
Enum Wire Format
String enum schemas serialize as standard JSON Schema:
{
"type": "string",
"enum": ["user", "admin", "moderator"]
}
JsonEnum uses the same standard output. When enum values include display titles, it emits JSON Schema oneOf entries with const and title; when a titled enum is used as array items, it emits anyOf entries. Mixed primitive enums without titles emit an enum array without a type. Legacy serialized input using type: 'enum' / values or enumNames is still accepted by parsers.
Const Values
Use JsonSchema.constValue(...) when a property must equal exactly one JSON value:
'confirmation': JsonSchema.constValue('DELETE')
This emits standard JSON Schema const and validates only the constant value.
Nullable and Type Unions
JsonSchema.fromJson(...) also accepts simple JSON Schema type arrays, such as nullable string schemas:
{
"type": ["string", "null"]
}
These are parsed as a union of the listed primitive schema types and validate only values matching one of those types.
Tool Annotations
Provide behavioral hints to clients:
Read-Only Tools
server.registerTool(
'get-user-stats',
description: 'Get user statistics',
annotations: ToolAnnotations(readOnlyHint: true), // No side effects
inputSchema: JsonSchema.object(properties: {...}),
callback: (args, extra) async {
final stats = await database.getUserStats();
return CallToolResult(
content: [TextContent(text: jsonEncode(stats))],
);
},
);
Destructive Tools
server.registerTool(
'delete-all-data',
description: 'Permanently delete all data',
annotations: ToolAnnotations(
readOnlyHint: false,
destructiveHint: true, // Warn users!
),
inputSchema: JsonSchema.object(
properties: {
'confirmation': JsonSchema.constValue('DELETE'),
},
required: ['confirmation'],
),
callback: (args, extra) async {
await database.deleteAll();
return CallToolResult(
content: [TextContent(text: 'All data deleted')],
);
},
);
Idempotent Tools
server.registerTool(
'update-cache',
description: 'Update cache entry',
annotations: ToolAnnotations(idempotentHint: true), // Safe to retry
inputSchema: JsonSchema.object(properties: {...}),
callback: (args, extra) async {
await cache.set(args['key'], args['value']);
return CallToolResult(
content: [TextContent(text: 'Cache updated')],
);
},
);
Open World Tools
server.registerTool(
'search-web',
description: 'Search the internet',
annotations: ToolAnnotations(openWorldHint: true), // Results vary over time
inputSchema: JsonSchema.object(properties: {...}),
callback: (args, extra) async {
final results = await webSearch(args['query']);
return CallToolResult(
content: [TextContent(text: jsonEncode(results))],
);
},
);
Stable MCP tool annotations are title, readOnlyHint, destructiveHint,
idempotentHint, and openWorldHint. Legacy priority and audience payloads
still parse into deprecated Dart fields for compatibility, but they are not
emitted by ToolAnnotations.toJson().
Content Types
Text Content
Return TextContent for human-readable or model-readable text. Put it inside a
CallToolResult so the response retains the MCP tool-result envelope.
return CallToolResult(
content: [
TextContent(text: 'Simple text response'),
],
);
Image Content
Return an ImageContent block when the model or host needs inline image bytes.
Base64-encode the bytes and provide the real media type so the receiver can
decode them safely.
return CallToolResult(
content: [
ImageContent(
data: base64Encode(imageBytes),
mimeType: 'image/png',
),
],
);
Theme hints belong to McpIcon.theme on icon-bearing resources and tools; the
wire shape for ImageContent does not include a theme field.
Audio Content
Return audio as a base64-encoded AudioContent block inside the tool result.
Use a MIME type that describes the encoded bytes; do not place filesystem paths
or raw binary bytes on the wire.
return CallToolResult(
content: [
AudioContent(
data: base64Encode(audioBytes),
mimeType: 'audio/wav',
),
],
);
Resource Link Content
return CallToolResult(
content: [
TextContent(text: 'Open the generated report:'),
ResourceLink(
uri: 'file:///reports/summary.md',
name: 'summary-report',
mimeType: 'text/markdown',
icons: [
McpIcon(
src: 'https://example.com/icons/report.png',
mimeType: 'image/png',
theme: IconTheme.light,
),
],
),
],
);
Multiple Content Types
return CallToolResult(
content: [
TextContent(text: 'Analysis Results:'),
ImageContent(
data: base64Encode(chart),
mimeType: 'image/png',
),
TextContent(text: 'See attached chart for details.'),
],
);
Embedded Resources
Use EmbeddedResource when the resource contents should travel with the tool
result instead of requiring a follow-up resources/read request. Keep the
embedded URI stable enough for attribution and include the correct MIME type.
return CallToolResult(
content: [
TextContent(text: 'Generated report:'),
EmbeddedResource(
resource: TextResourceContents(
uri: 'file:///reports/analysis.txt',
text: extractedReportText,
mimeType: 'text/plain',
),
),
],
);
Error Handling
Return Error Results
Expected tool-domain failures should be returned as
CallToolResult(isError: true, ...) so a model can inspect the explanation and
retry. Reserve thrown McpError values for JSON-RPC or MCP protocol failures.
server.registerTool(
'divide',
inputSchema: JsonSchema.object(properties: {...}),
callback: (args, extra) async {
final a = args['a'] as num;
final b = args['b'] as num;
if (b == 0) {
return CallToolResult(
isError: true,
content: [TextContent(text: 'Error: Division by zero')],
);
}
return CallToolResult(
content: [TextContent(text: '${a / b}')],
);
},
);
Tool-domain Permission Errors
For deliverable tool-domain failures such as permission denials, return a tool
result with isError: true instead of using JSON-RPC structural error codes.
Use the same result shape for recoverable input and business-rule validation.
Reserve McpError/ErrorCode for protocol-level failures such as malformed
requests, unknown tools, or server failures.
The built-in inputSchema rejection uses this error-result behavior for MCP
2025-11-25 and newer. When the server negotiates MCP 2025-06-18 or an
earlier frozen specification, including MCP 2024-10-07, schema-invalid
arguments retain the historical JSON-RPC invalidParams response.
Task-augmented calls also reject schema-invalid arguments as invalidParams
before task acceptance; accepted calls must first return CreateTaskResult,
with their eventual CallToolResult available through tasks/result.
If a successful handler result does not satisfy the tool's registered
outputSchema, or omits required structuredContent, the SDK returns JSON-RPC
internalError under MCP 2025-11-25 and 2026-07-28. An input or output
schema the SDK cannot compile is reported through the same server-error
channel. These are server-side contract failures, not invalid client requests.
MCP 2025-06-18 and earlier peers retain the historical invalidParams code
for these cases. An output schema that accepts JSON null still requires the
structuredContent key; return CallToolResult.fromStructuredNull() rather
than omitting structured content.
server.registerTool(
'admin-action',
inputSchema: JsonSchema.object(properties: {...}),
callback: (args, extra) async {
final principal = verifiedPrincipalFor(extra);
if (!await isAdmin(principal)) {
return CallToolResult(
isError: true,
content: [TextContent(text: 'Admin privileges required')],
);
}
// Perform admin action...
return CallToolResult(content: []);
},
);
Validation Errors
server.registerTool(
'custom-validation',
inputSchema: JsonSchema.object(properties: {...}),
callback: (args, extra) async {
// Custom business logic validation
if (!isValid(args)) {
return CallToolResult(
isError: true,
content: [
TextContent(text: 'Validation failed: ${getErrors(args)}'),
],
);
}
return CallToolResult(...);
},
);
Progress Notifications
Long-running tools can report progress back to the client. This provides feedback to the user about the operation's status.
Sending Progress
The callback function receives an extra parameter (of type RequestHandlerExtra) which exposes the sendProgress method.
server.registerTool(
'long-running-task',
description: 'A task that takes some time',
inputSchema: JsonSchema.object(properties: {...}),
callback: (args, extra) async {
final totalSteps = 10;
for (var i = 1; i <= totalSteps; i++) {
await performStep(i);
// Send progress notification
// This automatically checks if the client requested progress (via progressToken)
await extra.sendProgress(
i.toDouble(),
total: totalSteps.toDouble(),
message: 'Processing step $i',
);
}
return CallToolResult(
content: [TextContent(text: 'Task completed')],
);
},
);
Cancellation Support
Tools should also check for cancellation, especially if they are long-running.
When extra.signal.aborted is set, stop work promptly and clean up local state.
The protocol may suppress any response after cancellation, so do not rely on a
thrown error being delivered to the client.
server.registerTool(
'cancelable-task',
inputSchema: JsonSchema.object(properties: {...}),
callback: (args, extra) async {
// Check if cancelled at the start
if (extra.signal.aborted) {
await cleanupPartialWork();
return CallToolResult(
isError: true,
content: [TextContent(text: 'Task cancelled')],
);
}
for (var i = 0; i < 1000; i++) {
// Check for cancellation during loop
if (extra.signal.aborted) {
await cleanupPartialWork();
return CallToolResult(
isError: true,
content: [TextContent(text: 'Task cancelled')],
);
}
await processItem(i);
// Report progress
await extra.sendProgress(i.toDouble(), total: 1000);
}
return CallToolResult(content: [TextContent(text: 'Done')]);
},
);
Long-running tool calls
MCP 2026-07-28 Tasks extension
This experimental extension is server-directed and is not currently an
official MCP extension or part of Core conformance. Both peers advertise
io.modelcontextprotocol/tasks, the client makes a normal tools/call, and
the server may return a durable CreateTaskExtensionResult. See the
server guide for the low-level
creation and tasks/get handlers.
Opt the client in through its per-request capabilities. callTool() then polls
tasks/get and returns the completed tool result; do not pass the legacy
task option:
final client = McpClient(
const Implementation(name: 'task-client', version: '1.0.0'),
options: McpClientOptions(
capabilities: ClientCapabilities(
extensions: withMcpTasksExtension(),
),
),
);
await client.connect(transport);
final result = await client.callTool(
const CallToolRequest(
name: 'slow-tool',
arguments: {'input': 'value'},
),
);
The extension uses tasks/get, tasks/update, and tasks/cancel. It removes
tasks/list, tasks/result, legacy tasks.requests.* capability fields, and
the client-supplied task-creation option.
MCP 2025-11-25 legacy task augmentation
For legacy peers, the server must advertise tasks.requests.tools.call; a
top-level tasks capability is not enough. registerToolTask() advertises that
subcapability automatically when called before connect().
final server = McpServer(
const Implementation(name: 'task-server', version: '1.0.0'),
);
server.experimental.registerToolTask(
'slow-tool',
description: 'Runs asynchronously and returns a task first',
inputSchema: const ToolInputSchema(),
// Defaults to ToolExecution(taskSupport: 'required'). Use 'optional' if the
// same tool can also return an immediate CallToolResult.
execution: const ToolExecution(taskSupport: 'required'),
handler: SlowToolTaskHandler(),
);
await server.connect(transport);
If a task-based tool must be registered after connect(), pre-advertise the
capability in McpServerOptions before connecting:
final server = McpServer(
const Implementation(name: 'task-server', version: '1.0.0'),
options: const McpServerOptions(
capabilities: ServerCapabilities(
tasks: ServerCapabilitiesTasks(
requests: ServerCapabilitiesTasksRequests(
tools: ServerCapabilitiesTasksTools(
call: ServerCapabilitiesTasksToolsCall(),
),
),
),
),
),
);
Legacy clients can use TaskClient.callToolStream(). It verifies
tasks.requests.tools.call and the tool's execution.taskSupport metadata
before sending the client-selected task option.
final taskClient = TaskClient(client);
await for (final message in taskClient.callToolStream(
'slow-tool',
{'input': 'value'},
task: {'ttl': 60000, 'pollInterval': 1000},
)) {
// Handle TaskCreatedMessage, TaskStatusMessage, TaskResultMessage,
// or TaskErrorMessage.
}
Real-World Examples
API Integration
const nwsApiBase = 'https://api.weather.gov';
server.registerTool(
'get-alerts',
description: 'Get weather alerts for a US state',
inputSchema: JsonSchema.object(
properties: {
'state': JsonSchema.string(
description: 'Two-letter state code, for example CA or NY',
),
},
required: ['state'],
),
callback: (args, extra) async {
final state = (args['state'] as String?)?.toUpperCase();
if (state == null || state.length != 2) {
return const CallToolResult(
content: [TextContent(text: 'Invalid state code provided.')],
isError: true,
);
}
final alertsData = await makeNWSRequest('$nwsApiBase/alerts?area=$state');
if (alertsData == null) {
return CallToolResult.fromContent(
[const TextContent(text: 'Failed to retrieve alerts data.')],
);
}
final features = alertsData['features'] as List<dynamic>? ?? [];
if (features.isEmpty) {
return CallToolResult.fromContent(
[TextContent(text: 'No active alerts for $state.')],
);
}
return CallToolResult.fromContent(
[TextContent(text: 'Active alerts for $state: ${features.length}')],
);
},
);
See example/weather.dart for the complete National Weather Service example, including request headers, forecast lookup, and alert formatting.
Database Query
server.registerTool(
'query-users',
description: 'Query user database',
inputSchema: JsonSchema.object(
properties: {
'filters': JsonSchema.object(
properties: {
'age_min': JsonSchema.integer(),
'age_max': JsonSchema.integer(),
'role': JsonSchema.string(),
},
),
'limit': JsonSchema.integer(
minimum: 1,
maximum: 100,
defaultValue: 10,
),
},
),
callback: (args, extra) async {
final filters = args['filters'] as Map<String, dynamic>?;
final limit = args['limit'] as int? ?? 10;
final users = await database.query(
filters: filters,
limit: limit,
);
return CallToolResult(
content: [
TextContent(
text: jsonEncode({
'count': users.length,
'users': users,
}),
),
],
);
},
);
File Operations
server.registerTool(
'read-file',
description: 'Read file contents',
annotations: ToolAnnotations(readOnlyHint: true),
inputSchema: JsonSchema.object(
properties: {
'path': JsonSchema.string(description: 'File path'),
},
required: ['path'],
),
callback: (args, extra) async {
final safePath = canonicalizeWithinRoot(
allowedRoot,
args['path'] as String,
);
if (safePath == null) {
return const CallToolResult(
isError: true,
content: [TextContent(text: 'Access denied')],
);
}
final file = File(safePath);
if (!await file.exists()) {
return const CallToolResult(
isError: true,
content: [TextContent(text: 'File not found')],
);
}
final content = await file.readAsString();
return CallToolResult(
content: [TextContent(text: content)],
);
},
);
canonicalizeWithinRoot must normalize the path, resolve symlinks, and reject
any result outside the configured root before the file is accessed.
Best Practices
1. Clear Descriptions
// ✅ Good
server.registerTool(
'search',
description: 'Search the knowledge base using keywords. '
'Returns up to 10 most relevant results ranked '
'by relevance score.',
...
);
// ❌ Bad
server.registerTool(
'search',
description: 'Searches',
...
);
2. Comprehensive Schemas
// ✅ Good - descriptive, with validation
inputSchema: JsonSchema.object(
properties: {
'query': JsonSchema.string(
description: 'Search query (keywords)',
minLength: 1,
maxLength: 200,
),
},
required: ['query'],
)
// ❌ Bad - minimal, no validation
inputSchema: JsonSchema.object(
properties: {
'query': JsonSchema.string(),
},
)
3. Type Safety
// ✅ Good - type checking
callback: (args, extra) async {
final count = args['count'] as int;
if (count < 1 || count > 100) {
return const CallToolResult(
isError: true,
content: [TextContent(text: 'Count must be between 1 and 100.')],
);
}
...
}
// ❌ Bad - no type checking
callback: (args, extra) async {
final count = args['count']; // Could be anything!
...
}
4. Error Handling
// ✅ Good - comprehensive error handling
callback: (args, extra) async {
try {
final result = await riskyOperation(args);
return CallToolResult(
content: [TextContent(text: result)],
);
} on NetworkException catch (error, stackTrace) {
logger.warning('Tool network failure', error, stackTrace);
return const CallToolResult(
isError: true,
content: [TextContent(text: 'Temporary network failure')],
);
} catch (error, stackTrace) {
logger.severe('Unexpected tool failure', error, stackTrace);
return const CallToolResult(
isError: true,
content: [TextContent(text: 'Operation failed')],
);
}
}
// ❌ Bad - unhandled exceptions
callback: (args, extra) async {
final result = await riskyOperation(args); // May throw!
return CallToolResult(
content: [TextContent(text: result)],
);
}
5. Security
// ✅ Good - validate inputs, check permissions
callback: (args, extra) async {
final safePath = canonicalizeWithinRoot(
allowedRoot,
args['path'] as String,
);
if (safePath == null) {
return CallToolResult(
isError: true,
content: [TextContent(text: 'Access denied')],
);
}
// Use identity verified by the server, never a caller-supplied user ID.
final principal = verifiedPrincipalFor(extra);
if (!hasPermission(principal, safePath)) {
return CallToolResult(
isError: true,
content: [TextContent(text: 'Insufficient permissions')],
);
}
return CallToolResult(...);
}
// ❌ Bad - no validation or security checks
callback: (args, extra) async {
final path = args['path'] as String;
final file = File(path); // Direct file access!
return CallToolResult(...);
}
Canonicalize and enforce the allowed root before authorization and file access. Your application must propagate a server-verified principal into the handler; see the OAuth server guide for the token-verification boundary.
Testing Tools
import 'package:test/test.dart';
void main() {
test('tool execution', () async {
// Setup
final server = McpServer(
Implementation(name: 'test', version: '1.0.0'),
);
server.registerTool(
'add',
inputSchema: JsonSchema.object(
properties: {
'a': JsonSchema.number(),
'b': JsonSchema.number(),
},
),
callback: (args, extra) async {
final sum = (args['a'] as num) + (args['b'] as num);
return CallToolResult(
content: [TextContent(text: '$sum')],
);
},
);
// Create client and connect (see Stream transport)
final client = await createTestClient(server);
// Test
final result = await client.callTool(CallToolRequest(
name: 'add',
arguments: {'a': 5, 'b': 3},
));
expect(
result.content.first,
isA<TextContent>().having((content) => content.text, 'text', '8'),
);
});
}
Next Steps
- Server Guide - Complete server guide
- Examples - More tool examples