Server Guide

July 31, 2026 · View on GitHub

Complete guide to building MCP servers with the Dart SDK.

Table of Contents

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',
        ),
      ],
    );
  },
);
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

  1. Client sends request
  2. Server validates request
  3. Server calls appropriate handler
  4. 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