mcp_sse_server 0.2.1
mcp_sse_server: ^0.2.1 copied to clipboard
A Dart implementation of the Model Context Protocol server side: protocol types, JSON-RPC 2.0 plumbing, pluggable transports, and a high-level API for registering tools, resources, and prompts.
// A small but complete MCP server: two tools, a resource, a resource
// template, and a prompt, served over Streamable HTTP with SSE.
//
// Run it with:
//
// dart run example/example.dart
//
// then point an MCP client at the printed URL.
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:mcp_sse_server/mcp_sse_server.dart';
/// The note store, one per session.
///
/// The handlers below read it live, so a note added through `add_note` is
/// visible to `resources/read` on the very next call — and one client's notes
/// are invisible to another's.
Map<String, String> _newNotes() => {
'welcome': 'This note lives inside the example server.',
};
/// The one place note URIs are built and parsed, so the two never drift.
final UriTemplate _noteUri = UriTemplate('note://{id}');
McpServer buildServer() {
final notes = _newNotes();
final server = McpServer(
name: 'example-server',
version: '0.1.0',
title: 'Example MCP Server',
instructions:
'Use "roll_dice" for randomness and "add_note" to store text. '
'Notes are readable as note://<id> resources.',
);
server.addTool(Tool(
name: 'roll_dice',
title: 'Roll dice',
description: 'Rolls a number of dice with the given number of sides.',
inputSchema: {
'type': 'object',
'properties': {
'count': {'type': 'integer', 'minimum': 1, 'maximum': 100},
'sides': {'type': 'integer', 'minimum': 2, 'default': 6},
},
'required': ['count'],
},
outputSchema: {
'type': 'object',
'properties': {
'rolls': {
'type': 'array',
'items': {'type': 'integer'},
},
'total': {'type': 'integer'},
},
'required': ['rolls', 'total'],
},
annotations:
const ToolAnnotations(readOnlyHint: true, openWorldHint: false),
handler: (arguments, context) async {
final count = (arguments['count'] as num).toInt();
final sides = (arguments['sides'] as num?)?.toInt() ?? 6;
final random = Random();
final rolls = <int>[];
// One notification per die would drown the client, so report in steps.
final step = (count / 10).ceil();
for (var i = 0; i < count; i++) {
// Long loops should stay responsive to cancellation and report progress.
context.throwIfCancelled();
if ((i + 1) % step == 0 || i + 1 == count) {
await context.reportProgress(i + 1, total: count, message: 'rolling');
}
rolls.add(random.nextInt(sides) + 1);
}
return CallToolResult.structured({
'rolls': rolls,
'total': rolls.fold<int>(0, (sum, roll) => sum + roll),
});
},
));
server.addTool(Tool(
name: 'add_note',
title: 'Add a note',
description: 'Stores a note and returns a link to it.',
inputSchema: {
'type': 'object',
'properties': {
'id': {'type': 'string'},
'text': {'type': 'string'},
},
'required': ['id', 'text'],
},
annotations: const ToolAnnotations(idempotentHint: true),
handler: (arguments, context) async {
final id = arguments['id'] as String;
final text = arguments['text'] as String;
notes[id] = text;
final uri = _noteUri.expand({'id': id});
await context.log(LoggingLevel.info, 'stored note "$id"');
// Tell the client its cached resource list is stale.
await context.server.notifyResourceListChanged();
await context.server.notifyResourceUpdated(uri);
return CallToolResult(content: [
TextContent('Saved note "$id".'),
ResourceLink(
uri: uri,
name: id,
mimeType: 'text/plain',
size: utf8.encode(text).length,
),
]);
},
));
// A fixed URI the client can rely on. The reader looks the text up on every
// call, so editing the note through `add_note` is reflected here.
server.addResource(Resource(
uri: _noteUri.expand({'id': 'welcome'}),
name: 'welcome',
title: 'Welcome note',
mimeType: 'text/plain',
reader: (uri, context) => ReadResourceResult([
TextResourceContents(
uri: uri, text: notes['welcome']!, mimeType: 'text/plain'),
]),
));
server.addResourceTemplate(ResourceTemplate(
uriTemplate: _noteUri.template,
name: 'note',
title: 'A stored note',
mimeType: 'text/plain',
// `welcome` is registered above as a fixed resource; listing it here too
// would show the client the same URI twice.
lister: () => [
for (final entry in notes.entries)
if (entry.key != 'welcome')
ResourceInfo(
uri: _noteUri.expand({'id': entry.key}),
name: entry.key,
mimeType: 'text/plain',
size: utf8.encode(entry.value).length,
),
],
completer: (request, context) => CompleteResult(notes.keys
.where((id) => id.startsWith(request.argumentValue))
.toList()),
reader: (uri, variables, context) {
final text = notes[variables['id']];
if (text == null) throw McpError.resourceNotFound(uri);
return ReadResourceResult([
TextResourceContents(uri: uri, text: text, mimeType: 'text/plain'),
]);
},
));
server.addPrompt(Prompt(
name: 'summarize_note',
title: 'Summarize a note',
description: 'Asks the model to summarize a stored note.',
arguments: const [
PromptArgument(
name: 'id',
description: 'The id of the note to summarize.',
required: true,
),
],
completer: (request, context) => CompleteResult(notes.keys
.where((id) => id.startsWith(request.argumentValue))
.toList()),
handler: (arguments, context) {
final id = arguments['id'] as String;
final text = notes[id];
if (text == null) {
throw McpError.invalidParams('There is no note called "$id"');
}
return GetPromptResult(
description: 'Summarize the note "$id"',
messages: [
PromptMessage.user('Summarize the following note in one sentence:'),
PromptMessage(role: Role.user, content: TextContent(text)),
],
);
},
));
return server;
}
Future<void> main(List<String> arguments) async {
final port = arguments.isEmpty ? 8080 : int.parse(arguments.first);
final http = await McpHttpServer.bind(
onSession: buildServer,
port: port,
sessionIdleTimeout: const Duration(minutes: 30),
);
stderr.writeln('MCP server listening on ${http.endpoint}');
stderr.writeln('Press Ctrl-C to stop.');
await ProcessSignal.sigint.watch().first;
await http.close(force: true);
}