Complete guide to implementing MCP tools.
Tools are functions that AI can call to perform actions. They are the primary way for clients to interact with server capabilities.
server.registerTool(
'tool-name',
description: 'What the tool does',
inputSchema: ToolInputSchema(
properties: {
'param': JsonSchema.string(),
},
),
callback: (args, extra) async {
// Process request
return CallToolResult(
content: [TextContent(text: 'result')],
);
},
);// 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(),
},
)server.registerTool(
'create-user',
inputSchema: ToolInputSchema(
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')],
);
},
);Provide behavioral hints to clients:
server.registerTool(
'get-user-stats',
description: 'Get user statistics',
annotations: ToolAnnotations(readOnly: true), // No side effects
inputSchema: ToolInputSchema(properties: {...}),
callback: (args, extra) async {
final stats = await database.getUserStats();
return CallToolResult(
content: [TextContent(text: jsonEncode(stats))],
);
},
);server.registerTool(
'delete-all-data',
description: 'Permanently delete all data',
annotations: ToolAnnotations(
readOnly: false,
destructive: true, // Warn users!
),
inputSchema: ToolInputSchema(
properties: {
'confirmation': JsonSchema.string(constValue: 'DELETE'),
},
required: ['confirmation'],
),
callback: (args, extra) async {
await database.deleteAll();
return CallToolResult(
content: [TextContent(text: 'All data deleted')],
);
},
);server.registerTool(
'update-cache',
description: 'Update cache entry',
annotations: ToolAnnotations(idempotent: true), // Safe to retry
inputSchema: ToolInputSchema(properties: {...}),
callback: (args, extra) async {
await cache.set(args['key'], args['value']);
return CallToolResult(
content: [TextContent(text: 'Cache updated')],
);
},
);server.registerTool(
'search-web',
description: 'Search the internet',
annotations: ToolAnnotations(openWorld: true), // Results vary over time
inputSchema: ToolInputSchema(properties: {...}),
callback: (args, extra) async {
final results = await webSearch(args['query']);
return CallToolResult(
content: [TextContent(text: jsonEncode(results))],
);
},
);return CallToolResult(
content: [
TextContent(text: 'Simple text response'),
],
);return CallToolResult(
content: [
ImageContent(
data: base64Encode(imageBytes),
mimeType: 'image/png',
theme: 'dark', // optional: 'light' | 'dark'
),
],
);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,
),
],
),
],
);return CallToolResult(
content: [
TextContent(text: 'Analysis Results:'),
ImageContent(
data: base64Encode(chart),
mimeType: 'image/png',
),
TextContent(text: 'See attached chart for details.'),
],
);return CallToolResult(
content: [
TextContent(text: 'Generated report:'),
EmbeddedResource(
resource: ResourceReference(
uri: 'file:///reports/analysis.pdf',
type: 'resource',
),
),
],
);server.registerTool(
'divide',
inputSchema: ToolInputSchema(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}')],
);
},
);server.registerTool(
'admin-action',
inputSchema: ToolInputSchema(properties: {...}),
callback: (args, extra) async {
if (!await isAdmin(args['userId'])) {
throw McpError(
ErrorCode.unauthorized,
'Admin privileges required',
);
}
// Perform admin action...
return CallToolResult(content: []);
},
);server.registerTool(
name: 'custom-validation',
inputSchema: {...},
callback: (args) async {
// Custom business logic validation
if (!isValid(args)) {
throw McpError(
ErrorCode.invalidParams,
'Validation failed: ${getErrors(args)}',
);
}
return CallToolResult(...);
},
);Long-running tools can report progress back to the client. This provides feedback to the user about the operation's status.
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: ToolInputSchema(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')],
);
},
);Tools should also check for cancellation, especially if they are long-running.
server.registerTool(
'cancelable-task',
inputSchema: ToolInputSchema(properties: {...}),
callback: (args, extra) async {
// Check if cancelled at the start
if (extra.signal.aborted) {
throw McpError(ErrorCode.requestCancelled, 'Task cancelled');
}
for (var i = 0; i < 1000; i++) {
// Check for cancellation during loop
if (extra.signal.aborted) {
throw McpError(ErrorCode.requestCancelled, 'Task cancelled');
}
await processItem(i);
// Report progress
await extra.sendProgress(i.toDouble(), total: 1000);
}
return CallToolResult(content: [TextContent(text: 'Done')]);
},
);server.registerTool(
'get-weather',
description: 'Get current weather for a city',
inputSchema: ToolInputSchema(
properties: {
'city': JsonSchema.string(description: 'City name'),
'units': JsonSchema.string(
enumValues: ['metric', 'imperial'],
defaultValue: 'metric',
),
},
required: ['city'],
),
callback: (args, extra) async {
final city = args['city'] as String;
final units = args['units'] as String? ?? 'metric';
final weather = await weatherApi.getCurrent(
city: city,
units: units,
);
return CallToolResult(
content: [
TextContent(
text: 'Weather in $city:\n'
'Temperature: ${weather.temp}°\n'
'Conditions: ${weather.description}',
),
],
);
},
);server.registerTool(
'query-users',
description: 'Query user database',
inputSchema: ToolInputSchema(
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,
}),
),
],
);
},
);server.registerTool(
'read-file',
description: 'Read file contents',
annotations: ToolAnnotations(readOnly: true),
inputSchema: ToolInputSchema(
properties: {
'path': JsonSchema.string(description: 'File path'),
'encoding': JsonSchema.string(
enumValues: ['utf8', 'latin1', 'ascii'],
defaultValue: 'utf8',
),
},
required: ['path'],
),
callback: (args, extra) async {
final path = args['path'] as String;
final encoding = args['encoding'] as String? ?? 'utf8';
// Validate path (security!)
if (!isPathAllowed(path)) {
throw McpError(
ErrorCode.invalidParams,
'Access denied: $path',
);
}
final file = File(path);
if (!await file.exists()) {
throw McpError(
ErrorCode.invalidParams,
'File not found: $path',
);
}
final content = await file.readAsString();
return CallToolResult(
content: [TextContent(text: content)],
);
},
);// ✅ 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',
...
);// ✅ Good - descriptive, with validation
inputSchema: ToolInputSchema(
properties: {
'query': JsonSchema.string(
description: 'Search query (keywords)',
minLength: 1,
maxLength: 200,
),
},
required: ['query'],
)
// ❌ Bad - minimal, no validation
inputSchema: ToolInputSchema(
properties: {
'query': JsonSchema.string(),
},
)// ✅ Good - type checking
callback: (args) async {
final count = args['count'] as int;
if (count < 1 || count > 100) {
throw McpError(ErrorCode.invalidParams, 'Count out of range');
}
...
}
// ❌ Bad - no type checking
callback: (args) async {
final count = args['count']; // Could be anything!
...
}// ✅ Good - comprehensive error handling
callback: (args) async {
try {
final result = await riskyOperation(args);
return CallToolResult(
content: [TextContent(text: result)],
);
} on NetworkException catch (e) {
return CallToolResult(
isError: true,
content: [TextContent(text: 'Network error: ${e.message}')],
);
} catch (e) {
return CallToolResult(
isError: true,
content: [TextContent(text: 'Unexpected error: $e')],
);
}
}
// ❌ Bad - unhandled exceptions
callback: (args) async {
final result = await riskyOperation(args); // May throw!
return CallToolResult(
content: [TextContent(text: result)],
);
}// ✅ Good - validate inputs, check permissions
callback: (args) async {
final path = args['path'] as String;
// Validate path
if (!isPathAllowed(path)) {
throw McpError(ErrorCode.unauthorized, 'Access denied');
}
// Check permissions
if (!hasPermission(args['userId'], path)) {
throw McpError(ErrorCode.unauthorized, 'Insufficient permissions');
}
// Sanitize input
final safePath = sanitizePath(path);
return CallToolResult(...);
}
// ❌ Bad - no validation or security checks
callback: (args) async {
final path = args['path'] as String;
final file = File(path); // Direct file access!
return CallToolResult(...);
}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: ToolInputSchema(
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.text, '8');
});
}- Server Guide - Complete server guide
- Examples - More tool examples