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.
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.
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},
};
}
}
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
.
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.
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.
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.
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.