Skip to content

Instantly share code, notes, and snippets.

@sma
Created April 5, 2025 11:11
Show Gist options
  • Save sma/47c3c50130f5e973d00886dec7aeae95 to your computer and use it in GitHub Desktop.
Save sma/47c3c50130f5e973d00886dec7aeae95 to your computer and use it in GitHub Desktop.

Writing a Dice Roller MCP Server in Dart

Let's write a MCP server in Dart. I'd like to make a tool that knows how to roll dice. This way, an LLM can get access to a real source of randomnes.

There's no ready-to-use Dart package, but there's a specification that details how to create such a server.

In its easiest form, the server reads JSON RPC requests from stdin and writes responses to stdout. It is allowed to use stderr for logging. All streams are UTF-8 encoded and adhere to the JSONL format, that is, each request must be a single line.

RPC

A JSON RPC 2.0 request looks like this:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "<what to call>",
  "params": {
    "<name>": <value>,
    ...
  }
}

The unique id must be either an integer or a string. If it is missing, this isn't a request but a notification which needs no response. The params property is optional. While the full JSON RPC spec allows any kind of parameters, the MCP expects an object a.k.a. map of named values here.

A JSON RPC response looks like this:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": <value>
}

or

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32600,
    "message": "<error message>",
    "data": {
      "<key>": <value>,
      ...
    }
  }
}

Because all requests are asynchronous, responses must repeat the unique id (either an integer or a string) passed to the request. The result is optional. The error has an optional data property which must be an object a.k.a. map of named values.

Dart Representation

Here's a Dart representation of a JSON RPC request (or notification):

class RpcRequest {
  RpcRequest(this.id, this.method, [this.params]);

  factory RpcRequest.fromJson(dynamic json) {
    if (json case {'jsonrpc': '2.0'}) {
      final id = json['id'];
      final method = json['method'];
      final params = json['params'];
      if ((id is int? || id is String) &&
          method is String &&
          params is Map<String, dynamic>?) {
        return RpcRequest(id, method, params);
      }
    }
    throw Exception('not a JSON RPC 2.0 request: $json');
  }

  final Object? id;
  final String method;
  final Map<String, dynamic>? params;

  dynamic toJson() => {
    'jsonrpc': '2.0',
    if (id != null) 'id': id,
    'method': method,
    if (params != null) 'params': params,
  };
}

And here's the JSON RPC response class:

class RpcResponse {
  RpcResponse.ok(this.id, this.result) : error = null;

  RpcResponse.error(this.id, int code, String message)
    : result = null,
      error = (code: code, message: message);

  final Object? id;
  final Object? result;
  final ({int code, String message})? error;

  dynamic toJson() {
    return {
      'jsonrpc': '2.0',
      'id': id,
      if (result != null) 'result': result,
      if (error != null)
        'error': {'code': error!.code, 'message': error!.message},
    };
  }
}

Proof of Concept Server

We can use them to setup a simple server:

class RpcServer {
  StreamSubscription? _subscription;

  void start() {
    _subscription = stdin //
        .transform(utf8.decoder)
        .transform(const LineSplitter())
        .map(json.decode)
        .map(RpcRequest.fromJson)
        .listen(_handleRequest);
  }

  void stop() {
    _subscription?.cancel();
  }

  void _handleRequest(RpcRequest request) {
    _writeResponse(RpcResponse.ok(request.id, 'You sent ${request.method}'));
  }

  void _writeResponse(RpcResponse response) {
    stdout.writeln(json.encode(response.toJson()));
  }
}

We can create a main function in bin/server.dart:

void main() {
  RpcServer().start();
}

And then try something like

echo '{"jsonrpc":"2.0","id":1,"method":"ping"}' | dart run

which should emit

{"jsonrpc":"2.0","id":1,"result":"You sent ping"}

on stdout.

Generalizing the Server

Let's generalize the server to register methods:

class RpcServer {
  RpcServer({required this.methods});

  final Map<String, RpcResponse Function(RpcRequest request)> methods;

  ...

  void _handleRequest(RpcRequest request) {
    try {
      final response = (methods[request.method] ?? _notFound)(request);
      if (response != null) _writeResponse(response);
    } catch (error) {
      _writeResponse(RpcResponse.error(request.id, -32603, '$error'));
    }
  }

  RpcResponse _notFound(RpcRequest request) {
    return RpcResponse.error(
      request.id,
      -32601,
      'Method not found: ${request.method}',
    );
  }

  ...
}

And use this in main:

void main() {
  RpcServer(
    methods: {
      'ping': (request) => 
        RpcResponse.ok(request.id, 'You sent ${request.method}'),
    },
  ).start();
}

The result should be still the same.

MCP

The MCP client sends an initialization method to perform a handshake. It announces its protocol version and capabilities which are then confirmed by the server and acknowledged by the client. Because capabilities might change dynamically, both client and server may inform the other side about that fact, but I'll ignore this aspect of the protocol.

Here's a minimal initialization expected from the client:

RpcRequest(1, 'initialize', {
  'protocolVersion': '2024-11-05',
});

The server will reply with:

RpcResponse.ok(1, {
  'protocolVersion': '2024-11-05',
  'capabilities': {
    'tools': {},
  },
  'serverInfo': {
    'name': 'MCP server',
    'version': '1.0.0',
  },
  'instructions': '…'
});

And then the client will ackknowledge with by sending a notification:

RpcRequest(null, 'notifications/initialized');

The tools capability is what interests us the most. This is what the client can ask for and then call to perform additional functions, like rolling dice as in our example.

MCP Implementation

But first, we need to implement the initialization.

Let's add a way to dispatch methods:

class RpcServer {
  ...

  late final _methods = <String, void Function(RpcRequest)>{
    'initialize': _initialize,
    'notifications/initialized': _initialized,
  };

  var _isInitialized = false;

  ...

  void _handleRequest(RpcRequest request) {
    (_methods[request.method] ?? _notFound)(request);
  }

  void _notFound(RpcRequest request) {
    _writeResponse(
      RpcResponse.error(request.id, -32601, 'Method not found: ${request.method}'),
    );
  }

And implement them:

  void _initialize(RpcRequest request) {
    _isInitialized = false;

    final protocolVersion = request.params?['protocolVersion'];
    if (protocolVersion != '2024-11-05') {
      return _writeResponse(
        RpcResponse.error(
          request.id,
          -2,
          'Protocol version not supported: $protocolVersion',
        ),
      );
    }
    _writeResponse(
      RpcResponse.ok(request.id, {
        'protocolVersion': protocolVersion,
        'capabilities': {'tools': {}},
        'serverInfo': {'name': 'MCP server', 'version': '1.0.0'},
      }),
    );
  }

  void _initialized(RpcRequest request) {
    _isInitialized = true;
  }      

  void Function(RpcRequest) _checkInitialized(void Function(RpcRequest) next) {
    return (request) {
      if (!_isInitialized) {
        return _writeResponse(
          RpcResponse.error(null, -3, 'Server not initialized'),
        );
      }
      next(request);
    };
  }

  ...
}

This dice roller server needs no initialization but it will complain if it hasn't been initialized. Also, I accept re-initialization at any time. I'm not sure whether that's a good idea or not, but it seems to cause no harm.

Because the server announced that it supports tools, the client will now ask for that tools using a tools/list method:

RpcRequest(2, 'tools/list');

The client will then list all callable tools (a.k.a. functions), describing their API with a subset of a JSON schema. In my case, I support a single call roll_dice with a single argument, formula that is something like XdY. This tool will return the sum of all dice and all individual dice values. The LLM will use the description property to understand the API, so don't leave them out.

Here's my server's response:

RpcResponse.ok(2, {
  'tools': [
    {
      'name': 'roll_dice',
      'description': 'roll random dice',
      'inputSchema': {
        'type': 'object',
        'properties': {
            'formula': {
                'type': 'string',
                'description': 'the dice formula XdY to roll X Y-sided dice',
            }
        },
        'required': ['formula'],
      },
    },
  ]
});

Later, the client will send a tools/call request like so:

RpcRequest(3, 'tools/call', {
  'name': 'roll_dice',
  'arguments': {
    'formula': '3d6'
  }
});

And the server will respond with:

RpcResponse.ok(3, {
  'content': [
    {
      'type': 'text',
      'text': 'Sum: 12\nIndividual results: 1 6 5'
    }
  ]
});

There's no way to return a structured output (yet), unfortunately. The text result must be unambigious and understandable by the client (the LLM). I decided on returning two lines of text, the first containing the sum of all dice and the second one, enumerating the individual results.

To add the tools support, I added this to the list of methods:

class RpcServer {
    ...

    'tools/list': _checkInitialized(_list),
    'tools/call': _checkInitialized(_call),

And implemented _list to discover the single hard-coded tool:

  void _list(RpcRequest request) {
    return _writeResponse(
      RpcResponse.ok(request.id, {
        'tools': [
          {
            'name': 'roll_dice',
            'description': 'roll random dice',
            'inputSchema': {
              'type': 'object',
              'properties': {
                'formula': {
                  'type': 'string',
                  'description': 'the dice formula XdY to roll X Y-sided dice',
                },
              },
              'required': ['formula'],
            },
          },
        ],
      }),
    );
  }

Using some kind of registry would have been a better way to do this, for sure. And yes, the "business logic", the actual dice roller, shouldn't be inlined in the server, I know, but for this simple example, I make an exception:

  void _call(RpcRequest request) {
    final name = request.params?['name'];
    final args = request.params?['arguments'] as Map<String, dynamic>? ?? {};
    if (name == 'roll_dice') {
      final formula = args['formula'];
      if (formula is String) {
        final match = RegExp(r'(\d+)d(\d+)').firstMatch(formula);
        if (match != null) {
          final count = int.parse(match[1]!);
          final sides = int.parse(match[2]!);
          if (count > 0 && count < 100 && sides > 1) {
            final r = Random();
            final results = List.generate(count, (_) => r.nextInt(sides) + 1);
            final sum = results.reduce((a, b) => a + b);
            return _writeResponse(
              RpcResponse.ok(request.id, {
                'content': [
                  {
                    'type': 'text',
                    'text': 'Total: $sum\nIndividual rolls: ${results.join(' ')}',
                  },
                ],
              }),
            );
          }
        }
      }
      return _writeResponse(
        RpcResponse.error(
          request.id,
          -4,
          'Invalid or missing formula: $formula',
        ),
      );
    }
    _writeResponse(RpcResponse.error(request.id, -5, 'Unknown tool: $name'));
  }

The server is ready to use.

Let's test it with Claude's desktop application.

We need to add this to the application's JSON configuration file:

{
  "mcpServers": {
    "dice_roller": {
      "command": "/absolute/path/to/bin/dart",
      "args": [
        "run",
        "/absolute/path/to/project/bin/dice_roller.dart"
      ]
    }
  }
}

Then restart the desktop application. The chat should now show a little hammer icon, signalling that there's a roll_dice tool available to roll, well, dice. Prompting something like "roll the stats for a DCC character" should repeatedly invoke this tool to roll 3d6. Claude is also clever enough to use this tool as a base for rolling 1d6+3 or 2d20kh1.

Scripting

On macOS and Linux, there's an easier way to run the server. Add this she-bang to the top of the bin/dice_roller.dart file, assuming that dart is in your PATH:

#!/usr/bin/env dart

And then make that file executable:

chmod +x bin/dice_roller.dart

Now, you can add the absolute path to that file to the MCP configuration file and there's no need for arguments anymore.

Since April, Copilot also supports MCP. It will automatically pick up the Claude configuration. But without Claude, Use Cmd+P MCP: Add Server, pick "stdio", enter the absolute path to dart or the executable file, enter the server name dice_roller and edit the args array in the JSON configuration file, if you need to do this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment