Server Guide
July 31, 2026 · View on GitHub
Complete guide to building MCP servers with the Dart SDK.
Table of Contents
- Creating a Server
- Server Capabilities
- Registering Tools
- Providing Resources
- Creating Prompts
- MCP Apps Metadata
- Long-running tasks
- Handling Client Requests
- Server Lifecycle
- Advanced Topics
Creating a Server
Basic Server Setup
import 'package:mcp_dart/mcp_dart.dart';
void main() async {
final server = McpServer(
Implementation(
name: 'my-server',
version: '1.0.0',
),
options: McpServerOptions(
capabilities: ServerCapabilities(
tools: ServerCapabilitiesTools(),
resources: ServerCapabilitiesResources(),
prompts: ServerCapabilitiesPrompts(),
),
),
);
// Register capabilities (tools, resources, prompts)
// Connect transport
final transport = StdioServerTransport();
await server.connect(transport);
}
Server Configuration Options
final server = McpServer(
Implementation(
name: 'my-server',
version: '1.0.0',
),
options: McpServerOptions(
capabilities: ServerCapabilities(
tools: ServerCapabilitiesTools(),
resources: ServerCapabilitiesResources(),
prompts: ServerCapabilitiesPrompts(),
),
),
);
Protocol Profile
Servers in mcp_dart 2.3 and later use McpProtocol.stable by default. They
advertise and accept the stateless MCP 2026-07-28 protocol alongside
legacy versions, including server/discover. Select the legacy profile
explicitly to advertise only MCP 2025-11-25 and earlier versions:
final server = McpServer(
const Implementation(name: 'my-server', version: '1.0.0'),
options: const McpServerOptions(
protocol: McpProtocol.legacy,
capabilities: ServerCapabilities(
tools: ServerCapabilitiesTools(),
),
),
);
Use McpServerOptions(protocol: McpProtocol.require2026) when the server
should reject legacy initialization.
When the server is hosted by StreamableMcpServer, pass the same profile to
both layers so HTTP routing and the factory-created protocol agree:
final httpServer = StreamableMcpServer(
protocol: McpProtocol.require2026,
serverFactory: (_) => McpServer(
const Implementation(name: 'my-server', version: '1.0.0'),
options: const McpServerOptions(protocol: McpProtocol.require2026),
),
);
Server Capabilities
Registering a tool, resource, or prompt declares the corresponding base capability. Optional behavior such as subscriptions, list-change notifications, logging, and tasks must be advertised explicitly so peers do not infer support from structure alone:
const McpServerOptions(
capabilities: ServerCapabilities(
tools: ServerCapabilitiesTools(listChanged: true),
resources: ServerCapabilitiesResources(
subscribe: true,
listChanged: true,
),
prompts: ServerCapabilitiesPrompts(listChanged: true),
logging: <String, dynamic>{},
),
);
Tool Capabilities
// Base tools support is declared when the first tool is registered.
server.registerTool('my-tool', callback: ...);
// Set ServerCapabilitiesTools(listChanged: true) before sending list changes.
Resource Capabilities
// Base resources support is declared when a resource is registered.
server.registerResource('Data', 'file:///data', null, readCallback);
// Advertise subscribe/listChanged explicitly before implementing either.
Prompt Capabilities
Advertise prompts.listChanged only when the server can notify clients after
its prompt registry changes. In an MCP 2026-07-28
subscriptions/listen handler, acknowledge promptsListChanged and send the
typed notification only on that request stream:
final acknowledged = request.listenParams.notifications.acknowledgedBy(
server.server.getCapabilities(),
);
await extra.sendSubscriptionAcknowledged(acknowledged);
if (acknowledged.promptsListChanged == true) {
await extra.sendSubscriptionNotification(
const JsonRpcPromptListChangedNotification(),
);
}
For an MCP 2025-11-25 stateful session, registering, updating, enabling, disabling, or removing a prompt through the high-level registry automatically sends the legacy global list-change notification when the server is connected and the capability was advertised:
final prompt = server.registerPrompt(
'my-prompt',
callback: (args, extra) async => const GetPromptResult(messages: []),
);
prompt.update(description: 'Updated prompt');
MCP Apps Metadata
Use TypeScript-style helper APIs to register app tools/resources with _meta.ui.
const resourceUri = 'ui://dashboard/view.html';
registerAppTool(
server,
'dashboard_show',
McpUiAppToolConfig(
meta: const {
'ui': {
'resourceUri': resourceUri,
},
},
),
(args, extra) async => const CallToolResult(
content: [TextContent(text: 'ok')],
),
);
registerAppResource(
server,
'Dashboard UI',
resourceUri,
const McpUiAppResourceConfig(
meta: {
'ui': {
'prefersBorder': true,
},
},
),
(uri, extra) async => ReadResourceResult(
contents: [
TextResourceContents(
uri: uri.toString(),
mimeType: mcpUiResourceMimeType,
text: '<!doctype html><html></html>',
meta: const McpUiResourceMeta(
prefersBorder: true,
).toMeta(),
),
],
),
);
For a complete example, see MCP Apps guide.
Registering Tools
Tools allow clients to execute actions through your server.
The existing registerTool, registerPrompt, registerResource, and
registerResourceTemplate APIs retain their mcp_dart 2.2.2 callback result
types. For an MCP 2026-07-28 callback that may return
InputRequiredResult, use the additive registerStatelessTool,
registerStatelessPrompt, registerStatelessResource, or
registerStatelessResourceTemplate counterpart. See the
transition guide for the
mapping and non-object output example.
Use registerStatelessTool for a non-object output schema even when its
callback always returns CallToolResult. Its RegisteredStatelessTool handle
keeps the complete outputJsonSchema and stateless callback available for
inspection and updateStateless() updates; the inherited outputSchema getter
remains the object-rooted compatibility view.
Simple Tool
server.registerTool(
'echo',
description: 'Echo back a message',
inputSchema: JsonSchema.object(
properties: {
'message': JsonSchema.string(),
},
required: ['message'],
),
callback: (args, extra) async {
final message = args['message'] as String;
return CallToolResult(
content: [TextContent(text: message)],
);
},
);
Tool with Complex Schema
server.registerTool(
'search-database',
description: 'Search database with filters',
inputSchema: JsonSchema.object(
properties: {
'query': JsonSchema.string(description: 'Search query'),
'filters': JsonSchema.object(
properties: {
'category': JsonSchema.string(),
'minPrice': JsonSchema.number(),
'maxPrice': JsonSchema.number(),
},
),
'limit': JsonSchema.integer(
minimum: 1,
maximum: 100,
defaultValue: 10,
),
},
required: ['query'],
),
callback: (args, extra) async {
final query = args['query'] as String;
final filters = args['filters'] as Map<String, dynamic>?;
final limit = args['limit'] as int? ?? 10;
final results = await database.search(
query: query,
filters: filters,
limit: limit,
);
return CallToolResult(
content: [
TextContent(
text: jsonEncode(results),
),
],
);
},
);
Reporting Progress
For long-running operations, you can report progress back to the client:
server.registerTool(
'long-task',
inputSchema: JsonSchema.object(properties: {}),
callback: (args, extra) async {
for (var i = 0; i < 100; i++) {
await Future.delayed(Duration(milliseconds: 100));
await extra.sendProgress(
i.toDouble(),
total: 100,
message: 'Processing item $i',
);
}
return CallToolResult(content: [TextContent(text: 'Done')]);
},
);
See Tools Documentation for more details.
Tool Annotations
Provide hints about tool behavior:
server.registerTool(
'delete-user',
description: 'Permanently delete a user account',
annotations: const ToolAnnotations(
destructiveHint: true,
idempotentHint: true,
),
inputSchema: JsonSchema.object(properties: {}),
callback: (args, extra) async {
// Delete logic
return CallToolResult(
content: [TextContent(text: 'User deleted')],
);
},
);
server.registerTool(
'get-user-info',
description: 'Get user information',
annotations: const ToolAnnotations(readOnlyHint: true),
inputSchema: JsonSchema.object(properties: {}),
callback: (args, extra) async {
// Get logic
return CallToolResult(
content: [TextContent(text: 'User info')],
);
},
);
Tool with Multiple Content Types
server.registerTool(
'generate-report',
description: 'Generate a report with chart',
inputSchema: JsonSchema.object(properties: {}),
callback: (args, extra) async {
final report = await generateReport(args);
final chart = await generateChart(report);
return CallToolResult(
content: [
TextContent(text: report.summary),
ImageContent(
data: base64Encode(chart),
mimeType: 'image/png',
),
],
);
},
);
Tool Returning a Resource Link
server.registerTool(
'latest-report',
description: 'Return a link to the latest generated report',
inputSchema: JsonSchema.object(properties: {}),
callback: (args, extra) async {
return CallToolResult(
content: [
TextContent(text: 'Latest report is available.'),
ResourceLink(
uri: 'file:///reports/latest.md',
name: 'latest-report',
mimeType: 'text/markdown',
),
],
);
},
);
Error Handling in Tools
server.registerTool(
'divide',
description: 'Divide two numbers',
inputSchema: JsonSchema.object(
properties: {
'a': JsonSchema.number(),
'b': JsonSchema.number(),
},
required: ['a', 'b'],
),
callback: (args, extra) async {
final a = args['a'] as num;
final b = args['b'] as num;
if (b == 0) {
// Return error content
return CallToolResult(
isError: true,
content: [
TextContent(text: 'Error: Division by zero'),
],
);
}
return CallToolResult(
content: [TextContent(text: '${a / b}')],
);
},
);
Providing Resources
Resources provide data and context to clients.
Simple Resource
server.registerResource(
'README',
'file:///docs/readme.md',
null,
(uri, extra) async {
final content = await File('README.md').readAsString();
return ReadResourceResult(
contents: [
TextResourceContents(
uri: 'file:///docs/readme.md',
text: content,
mimeType: 'text/markdown',
),
],
);
},
);
Resource with URI Template
Use URI templates when one resource definition represents a family of concrete URIs. Declare variables in the template, validate the expanded values in the callback, and return contents whose URI matches the requested concrete resource:
server.registerResourceTemplate(
'User Profile',
ResourceTemplateRegistration(
'users://{userId}/profile',
listCallback: null,
),
null,
(uri, vars, extra) async {
// Extract userId from variables
final userId = vars['userId'];
final profile = await database.getUserProfile(userId);
return ReadResourceResult(
contents: [
TextResourceContents(
uri: uri.toString(),
text: jsonEncode(profile),
mimeType: 'application/json',
),
],
);
},
);
Resource Template Completions
Resource template completion callbacks can use CompletionContext.arguments to
tailor suggestions based on other arguments the client already collected:
server.registerResourceTemplate(
'User Profile',
ResourceTemplateRegistration(
'users://{organization}/{userId}/profile',
listCallback: null,
completeCallbacksWithContext: {
'userId': (currentValue, context) async {
final organization = context?.arguments?['organization'];
return directory
.suggestUsers(organization: organization, prefix: currentValue);
},
},
),
null,
(uri, vars, extra) async {
final profile = await database.getUserProfile(vars['userId']);
return ReadResourceResult(
contents: [
TextResourceContents(
uri: uri.toString(),
text: jsonEncode(profile),
mimeType: 'application/json',
),
],
);
},
);
Multiple URI Template Variables
server.registerResourceTemplate(
'Project File',
ResourceTemplateRegistration(
'projects://{orgId}/{projectId}/files/{filePath}',
listCallback: null,
),
null,
(uri, vars, extra) async {
final orgId = vars['orgId'];
final projectId = vars['projectId'];
final filePath = vars['filePath'];
final fileContent = await storage.getFile(
orgId: orgId,
projectId: projectId,
path: filePath,
);
return ReadResourceResult(
contents: [
TextResourceContents(
uri: uri.toString(),
text: fileContent,
),
],
);
},
);
Query Parameter URI Templates (RFC 6570)
server.registerResourceTemplate(
'Entity List',
ResourceTemplateRegistration(
'entity://list{?status,assignee}',
listCallback: null,
),
null,
(uri, vars, extra) async {
final status = vars['status'] as String?;
final assignee = vars['assignee'] as String?;
final data = await repository.list(
status: status,
assignee: assignee,
);
return ReadResourceResult(
contents: [
TextResourceContents(
uri: uri.toString(),
text: jsonEncode(data),
mimeType: 'application/json',
),
],
);
},
);
Binary Resources
Use BlobResourceContents for non-text bytes. Base64-encode the payload and
provide its MIME type; clients decode blob after applying their normal
resource authorization and size limits.
server.registerResource(
'Company Logo',
'file:///images/logo.png',
null,
(uri, extra) async {
final bytes = await File('logo.png').readAsBytes();
return ReadResourceResult(
contents: [
BlobResourceContents(
uri: 'file:///images/logo.png',
blob: base64Encode(bytes),
mimeType: 'image/png',
),
],
);
},
);
Resource Updates
For MCP 2026-07-28, resource updates are delivered only on an acknowledged
subscriptions/listen stream. Inside that long-lived handler, use its
request-scoped extra object:
await extra.sendSubscriptionAcknowledged(
request.listenParams.notifications.acknowledgedBy(
server.server.getCapabilities(),
),
);
await extra.sendSubscriptionNotification(
JsonRpcResourceUpdatedNotification(
updatedParams: const ResourceUpdatedNotification(
uri: 'file:///data/metrics.json',
),
),
);
Retain the request-scoped extra for each active subscription, filter events
to the acknowledged resource URIs, and stop when extra.signal is aborted.
The global sendResourceUpdated() and sendResourceListChanged() helpers are
legacy MCP APIs. They continue to work in an initialization-era session but
are suppressed for stateless MCP because they cannot identify an active
subscription stream.
Register and read the resource normally in either profile:
server.registerResource(
'Metrics',
'file:///data/metrics.json',
null,
(uri, extra) async => ReadResourceResult(
contents: [
TextResourceContents(
uri: uri.toString(),
text: await File('metrics.json').readAsString(),
mimeType: 'application/json',
),
],
),
);
Creating Prompts
Prompts are reusable templates with arguments.
Simple Prompt
server.registerPrompt(
'review-code',
description: 'Generate code review prompt',
callback: (args, extra) async {
return GetPromptResult(
description: 'Review code for quality and best practices',
messages: [
PromptMessage(
role: PromptMessageRole.user,
content: TextContent(
text: 'Please review the following code for:\n'
'- Code quality\n'
'- Best practices\n'
'- Potential bugs\n'
'- Security issues',
),
),
],
);
},
);
Prompt with Arguments
server.registerPrompt(
'translate',
description: 'Generate translation prompt',
argsSchema: {
'target_language': PromptArgumentDefinition(
type: String,
description: 'Language to translate to',
required: true,
),
'formality': PromptArgumentDefinition(
type: String,
description: 'Formality level (casual, formal)',
required: false,
),
},
callback: (args, extra) async {
final language = args?['target_language'] as String;
final formality = args?['formality'] as String? ?? 'neutral';
return GetPromptResult(
description: 'Translate text to $language',
messages: [
PromptMessage(
role: PromptMessageRole.user,
content: TextContent(
text: 'Translate the following text to $language '
'with a $formality tone:',
),
),
],
);
},
);
Prompt Argument Completions
Prompt argument completions can also receive the request context. Use
completeWithContext when suggestions depend on other prompt arguments:
server.registerPrompt(
'translate',
description: 'Generate translation prompt',
argsSchema: {
'source_language': PromptArgumentDefinition(type: String),
'target_language': PromptArgumentDefinition(
type: String,
completable: CompletableField(
def: CompletableDef(
complete: (value) async => languageCatalog.suggest(value),
completeWithContext: (value, context) async {
final sourceLanguage = context?.arguments?['source_language'];
return languageCatalog.suggestTargets(
prefix: value,
sourceLanguage: sourceLanguage,
);
},
),
),
),
},
callback: (args, extra) async => GetPromptResult(
messages: [
PromptMessage(
role: PromptMessageRole.user,
content: TextContent(text: 'Translate using $args'),
),
],
),
);
Multi-Message Prompts
server.registerPrompt(
'brainstorm',
description: 'Brainstorming session prompt',
argsSchema: {
'topic': PromptArgumentDefinition(
type: String,
description: 'Topic to brainstorm',
required: true,
),
},
callback: (args, extra) async {
final topic = args?['topic'] as String;
return GetPromptResult(
messages: [
PromptMessage(
role: PromptMessageRole.user,
content: TextContent(
text: 'Let\'s brainstorm ideas about: $topic',
),
),
PromptMessage(
role: PromptMessageRole.assistant,
content: TextContent(
text: 'Great! I\'ll help you brainstorm. What aspect '
'of $topic interests you most?',
),
),
PromptMessage(
role: PromptMessageRole.user,
content: TextContent(
text: 'I\'m particularly interested in practical '
'applications.',
),
),
],
);
},
);
Prompt with Embedded Resources
server.registerPrompt(
'analyze-file',
description: 'Analyze a file',
argsSchema: {
'file_uri': PromptArgumentDefinition(
type: String,
description: 'URI of file to analyze',
required: true,
),
},
callback: (args, extra) async {
final fileUri = args?['file_uri'] as String;
final fileText = await File(Uri.parse(fileUri).toFilePath()).readAsString();
return GetPromptResult(
messages: [
PromptMessage(
role: PromptMessageRole.user,
content: EmbeddedResource(
resource: TextResourceContents(
uri: fileUri,
text: fileText,
mimeType: 'text/plain',
),
),
),
PromptMessage(
role: PromptMessageRole.user,
content: TextContent(
text: 'Please analyze this file for:\n'
'- Structure\n'
'- Content quality\n'
'- Potential improvements',
),
),
],
);
},
);
Long-running tasks
MCP has two task protocols that are not wire-compatible. Use the MCP 2026-07-28 experimental extension for stateless peers and retain the MCP 2025-11-25 API only when interoperating with a legacy peer. The extension is not currently an official MCP extension or part of Core conformance.
MCP 2026-07-28 Tasks extension
Declare io.modelcontextprotocol/tasks on both peers. Task creation is
server-directed: a normal tools/call may return CreateTaskExtensionResult
only when that request's client capabilities include the extension. Store the
task before returning it; the SDK verifies that the new ID is immediately
resolvable through tasks/get. High-level stateless tool registrations also
validate completed task output from tasks/get and notifications/tasks
against the schema captured at acceptance.
CreateTaskExtensionResult contains only the base task fields, even when its
seed status is terminal or requires input. The client retrieves the matching
status-specific result, error, or inputRequests through tasks/get before
acting on that status. A tasks/get result or notifications/tasks event must
contain exactly the detailed fields required by its status, and every embedded
input request requires the same per-request client capability as its standalone
RPC.
Updating the tool registration later does not change an in-flight task's
contract. The validation snapshot is discarded after a terminal result,
cancellation, or server close. Low-level custom tools/call handlers remain
responsible for their own output schema association. Give persisted tasks a
finite ttlMs unless indefinite retention is intentional, and expire the
application task/result record on the same schedule.
Treat task IDs as security-sensitive handles. Generate them with a
cryptographically secure source and enough entropy that they cannot be guessed
or enumerated. Authenticate and authorize every tasks/get, tasks/update, and
tasks/cancel request against the task's owner instead of treating knowledge of
an ID as sufficient access. Keep each inputRequests key unique for the entire
task lifetime. Hosts must apply the same user-consent, model-access, and trust
policy to embedded elicitation, sampling, and roots requests that they apply to
the equivalent standalone request; a task is not a higher-trust channel. The SDK
checks negotiated capabilities and wire shapes, but application persistence,
authorization, key uniqueness, and user-facing trust decisions remain the
implementer's responsibility.
The low-level handlers below show the minimum creation and polling shape:
final tasks = <String, TaskExtensionTask>{};
final server = McpServer(
const Implementation(name: 'task-server', version: '1.0.0'),
options: McpServerOptions(
protocol: McpProtocol.stable,
capabilities: ServerCapabilities(
tools: const ServerCapabilitiesTools(),
extensions: withMcpTasksExtension(),
),
),
);
server.server.setRequestHandler<JsonRpcGetTaskRequest>(
Method.tasksGet,
(request, extra) async {
final task = tasks[request.getParams.taskId];
if (task == null) {
throw McpError(ErrorCode.invalidParams.value, 'Task not found');
}
return GetTaskExtensionResult(task: task);
},
(id, params, meta) => JsonRpcGetTaskRequest.fromJson({
'jsonrpc': jsonRpcVersion,
'id': id,
'method': Method.tasksGet,
'params': params,
if (meta != null) '_meta': meta,
}),
);
server.server.setRequestHandler<JsonRpcCallToolRequest>(
Method.toolsCall,
(request, extra) async {
if (!(extra.clientCapabilities?.supportsTasksExtension ?? false)) {
return const CallToolResult(
content: [TextContent(text: 'Completed synchronously')],
);
}
final now = DateTime.now().toUtc().toIso8601String();
final task = TaskExtensionTask(
taskId: generateUUID(),
status: TaskStatus.working,
createdAt: now,
lastUpdatedAt: now,
ttlMs: 60000,
pollIntervalMs: 1000,
);
tasks[task.taskId] = task; // Persist durably in production.
return CreateTaskExtensionResult(task: task);
},
(id, params, meta) => JsonRpcCallToolRequest.fromJson({
'jsonrpc': jsonRpcVersion,
'id': id,
'method': Method.toolsCall,
'params': params,
if (meta != null) '_meta': meta,
}),
);
A complete service must also handle tasks/update and tasks/cancel for task
input and cancellation; successful handlers return
TaskExtensionAcknowledgementResult. The MCP 2026-07-28 extension has no
tasks/list,
tasks/result, or client-supplied task option. McpClient.callTool() polls
tasks/get and returns the final CallToolResult transparently.
ProtocolOptions.taskStore and taskMessageQueue preserve the MCP 2025-11-25
task-augmentation lifecycle and do not implement the modern extension's
detailed state or update semantics. A dual-era server may keep them configured
alongside the Tasks extension, whether the extension capability is supplied in
McpServerOptions or added with registerCapabilities. It must still register
the modern handlers above and own their persistence explicitly. Registering a
modern tasks/get or tasks/cancel handler replaces the legacy default for
that method, so a server that needs both forms through one instance must branch
on the request protocol metadata and return the result shape for that era.
MCP 2025-11-25 legacy task augmentation
The legacy flow advertises tasks.requests.*, lets clients opt in per request,
and includes tasks/list and tasks/result. Configure it through
server.experimental only for McpProtocol.legacy interoperability:
server.experimental.onListTasks((extra) async {
return ListTasksResult(
tasks: [
Task(
taskId: 'task-1',
status: TaskStatus.working,
statusMessage: 'Long operation is running',
ttl: null,
createdAt: DateTime.now().toIso8601String(),
lastUpdatedAt: DateTime.now().toIso8601String(),
),
],
);
});
server.experimental.onCancelTaskWithResult((taskId, extra) async {
// Logic to cancel the task
return Task(
taskId: taskId,
status: TaskStatus.cancelled,
statusMessage: 'Task cancelled',
ttl: null,
createdAt: DateTime.now().toIso8601String(),
lastUpdatedAt: DateTime.now().toIso8601String(),
);
});
server.experimental.onGetTask((taskId, extra) async {
// Return the task details
return Task(
taskId: taskId,
status: TaskStatus.working,
ttl: null,
createdAt: DateTime.now().toIso8601String(),
lastUpdatedAt: DateTime.now().toIso8601String(),
);
});
server.experimental.onTaskResult((taskId, extra) async {
// Return the task result
return CallToolResult(
content: [TextContent(text: 'Result')],
);
});
Handling Client Requests
Request Lifecycle
- Client sends request
- Server validates request
- Server calls appropriate handler
- Server returns result or error
Observability and deprecated protocol logging
Use application logging for new servers:
// Local application/SDK logging (not sent over MCP).
final logger = Logger('my-server');
logger.info('Server started');
logger.warn('Rate limit approaching');
logger.error('Database connection failed');
MCP 2026-07-28 deprecates notifications/message. The SDK keeps
sendLoggingMessage for legacy-session compatibility and suppresses that
global helper for stateless MCP. Request-scoped compatibility handlers must use
sendStatelessLoggingMessage with the originating request metadata and ID.
New stdio servers should log to stderr, deployed services should prefer
OpenTelemetry, and all logging must exclude secrets and personal identifying
information.
Server Lifecycle
Initialization
void main() async {
final server = McpServer(
Implementation(
name: 'my-server',
version: '1.0.0',
),
options: McpServerOptions(
capabilities: ServerCapabilities(
tools: ServerCapabilitiesTools(),
resources: ServerCapabilitiesResources(),
prompts: ServerCapabilitiesPrompts(),
),
),
);
// Register all capabilities before connecting
_registerTools(server);
_registerResources(server);
_registerPrompts(server);
// Connect transport
final transport = StdioServerTransport();
await server.connect(transport);
// Server is now running and handling requests
}
Shutdown
// Graceful shutdown
await server.close();
Error Recovery
try {
await server.connect(transport);
} catch (e) {
Logger('my-server').error('Failed to start server: $e');
rethrow;
}
Advanced Topics
Choose a transport per server instance
Register shared capabilities in a factory, then create a separate McpServer
instance for each transport. A connected server instance owns one transport;
run stdio and HTTP entry points in separate processes or isolates.
void main() async {
final server = McpServer(
Implementation(
name: 'multi-transport-server',
version: '1.0.0',
),
options: McpServerOptions(
capabilities: ServerCapabilities(
tools: ServerCapabilitiesTools(),
resources: ServerCapabilitiesResources(),
prompts: ServerCapabilitiesPrompts(),
),
),
);
// Register capabilities once
_registerCapabilities(server);
// Connect stdio transport
final stdioTransport = StdioServerTransport();
await server.connect(stdioTransport);
}
Custom Validation
server.registerTool(
'custom-validation',
description: 'Tool with custom validation',
inputSchema: {...},
callback: (args, extra) async {
// Custom validation logic
if (!_isValid(args)) {
return CallToolResult(
isError: true,
content: [
TextContent(
text: 'Validation failed: ${_getValidationError(args)}',
),
],
);
}
// Process request
return CallToolResult(
content: [TextContent(text: 'Success')],
);
},
);
Dynamic Capability Registration
final server = McpServer(Implementation(...), options: ...);
// Initial tools
server.registerTool('tool1', ...);
// Later, add more tools dynamically
void addNewTool() {
server.registerTool('tool2', ...);
}
When tools.listChanged is advertised and the server is connected,
registerTool sends the list-change notification. Do not send a duplicate
notification. The high-level registry currently returns its complete resource
list; implement a lower-level request handler if an application needs custom
pagination.
Best Practices
1. Clear Descriptions
// ✅ Good
server.registerTool(
'search',
description: 'Search the knowledge base using keywords. '
'Returns up to 10 most relevant results.',
...
);
// ❌ Bad
server.registerTool(
'search',
description: 'Searches stuff',
...
);
2. Comprehensive Schemas
// ✅ Good
inputSchema: JsonSchema.object(
properties: {
'query': JsonSchema.string(
description: 'Search keywords',
minLength: 1,
maxLength: 200,
),
'filters': JsonSchema.array(
items: JsonSchema.string(),
description: 'Optional category filters',
),
},
required: ['query'],
)
// ❌ Bad
inputSchema: JsonSchema.object(
properties: {
'query': JsonSchema.string(),
},
)
3. Proper Error Handling
// ✅ Good
callback: (args, extra) async {
try {
final result = await riskyOperation(args);
return CallToolResult(
content: [TextContent(text: result)],
);
} catch (error, stackTrace) {
logger.severe('Unexpected tool failure', error, stackTrace);
return const CallToolResult(
isError: true,
content: [TextContent(text: 'Operation failed')],
);
}
}
// ❌ Bad - uncaught exceptions
callback: (args, extra) async {
final result = await riskyOperation(args); // May throw!
return CallToolResult(
content: [TextContent(text: result)],
);
}
4. Use Appropriate Hints
// Destructive operations
server.registerTool(
'delete-account',
annotations: const ToolAnnotations(destructiveHint: true),
...
);
// Read-only operations
server.registerTool(
'get-stats',
annotations: const ToolAnnotations(readOnlyHint: true),
...
);
5. Resource URI Conventions
// ✅ Good - clear, hierarchical URIs
'file:///projects/myproject/README.md'
'db://users/123/profile'
'api://external/weather/current'
// ❌ Bad - unclear or flat URIs
'resource1'
'data'
'thing123'
Next Steps
- Tools Documentation - Deep dive into tools
- Transports Guide - Transport options