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.
mcp_sse_server #
A Dart implementation of the server half of the Model Context Protocol, revision 2025-06-18, served over Streamable HTTP with Server-Sent Events.
Register tools, resources, and prompts; the package handles JSON-RPC framing, capability negotiation, sessions, pagination, progress, cancellation, and the SSE plumbing.
Scope #
This package deliberately supports exactly one protocol revision and one transport:
- Revision
2025-06-18only. Earlier revisions differ in transport semantics and message shapes (notably JSON-RPC batching, which this revision removed). A client that asks for an older revision is answered with2025-06-18and may disconnect if it cannot speak it. - SSE only. There is no stdio transport. Server-to-client messages are always
delivered as Server-Sent Events over HTTP.
MemoryTransportexists for in-process clients and tests, and theTransportinterface is public if you need to plug in your own.
Install #
dependencies:
mcp_sse_server: ^0.2.0
Quick start #
import 'package:mcp_sse_server/mcp_sse_server.dart';
McpServer buildServer() => McpServer(
name: 'my-server',
version: '1.0.0',
instructions: 'Use add to sum two numbers.',
)..addTool(Tool(
name: 'add',
description: 'Adds two numbers.',
inputSchema: {
'type': 'object',
'properties': {
'a': {'type': 'number'},
'b': {'type': 'number'},
},
'required': ['a', 'b'],
},
handler: (arguments, context) => CallToolResult.text(
'${(arguments['a'] as num) + (arguments['b'] as num)}'),
));
Future<void> main() async {
final http = await McpHttpServer.bind(onSession: buildServer, port: 8080);
print('listening on ${http.endpoint}'); // http://127.0.0.1:8080/mcp
}
onSession runs once per initialize, so every client gets its own McpServer
and its own registry state. Point any MCP client that speaks Streamable HTTP at
the printed URL.
A fuller server — tools with output schemas, a resource, a resource template
with listing and completion, and a prompt — is in
example/example.dart.
The HTTP surface #
One endpoint carries the whole protocol:
| Method | Purpose |
|---|---|
POST |
Client-to-server messages. A request is answered on an SSE stream that closes once the reply is sent; a notification or response is acknowledged with 202. |
GET |
Opens the standalone SSE stream carrying server-initiated requests, notifications, and log messages. |
DELETE |
Terminates the session. |
OPTIONS |
CORS preflight. |
Sessions are identified by the Mcp-Session-Id header, issued in the response to
initialize, which is the only request that may open one. Only requests carrying
MCP-Protocol-Version: 2025-06-18, or no such header at all, are served — the
specification says to assume 2025-03-26 when it is absent, but this package
implements one revision, so honouring that would reject every header-less client.
Server-initiated traffic emitted before a client opens its standalone stream is
buffered (backlogLimit) and flushed in order when it connects. Once full, the
oldest entries are dropped; a dropped request fails its caller rather than
hanging it. The last replayBufferSize standalone events are retained so a
reconnect with Last-Event-ID resumes from where it left off — replies delivered
on a POST's own stream are not replayable, and a client that misses more than the
buffer holds loses the overflow. A new GET supersedes any previous standalone
stream, since a client whose connection dropped cannot tell the server about it.
Both limits are McpHttpServer.bind parameters.
Registering capabilities #
Capabilities are advertised based on what you register, so a server with no
prompts does not claim to support them. The one exception is logging, which is
always advertised: every server implements logging/setLevel and can emit
notifications/message whether or not anything is registered.
Tools #
server.addTool(Tool(
name: 'search',
title: 'Search the index',
description: 'Finds documents matching a query.',
inputSchema: {
'type': 'object',
'properties': {'query': {'type': 'string'}},
'required': ['query'],
},
outputSchema: {
'type': 'object',
'properties': {'hits': {'type': 'array', 'items': {'type': 'string'}}},
},
annotations: const ToolAnnotations(readOnlyHint: true),
handler: (arguments, context) async {
await context.reportProgress(1, total: 2, message: 'querying');
context.throwIfCancelled();
return CallToolResult.structured({'hits': await search(arguments['query'])});
},
));
An exception thrown from a tool handler becomes a CallToolResult with
isError: true, so the model can see the failure and react — the exception's
text is not included, since it would reach both the model's context and the
client; it goes to stderr instead. Throw an McpError when the call itself was
malformed and the client, not the model, should handle it.
Declaring an outputSchema obliges the handler to return structuredContent.
The payload is not validated against the schema — this package carries no
JSON Schema validator, and clients are expected to check. See "Known
deviations".
Resources #
server.addResource(Resource.text(
uri: 'config://app',
name: 'app-config',
text: await File('config.json').readAsString(),
));
server.addResourceTemplate(ResourceTemplate(
uriTemplate: 'file:///{+path}', // {+var} spans '/' ; {var} does not
name: 'project-file',
lister: () async => [for (final f in await listFiles()) ResourceInfo(uri: f, name: f)],
reader: (uri, variables, context) async => ReadResourceResult([
TextResourceContents(uri: uri, text: await File(variables['path']!).readAsString()),
]),
));
Fixed URIs are matched first, then templates in registration order. Call
notifyResourceUpdated(uri) to poke subscribers, and
notifyResourceListChanged() when the set of resources changes.
UriTemplate implements a deliberate subset of RFC 6570 — {var} and {+var}
only — and its constructor throws ArgumentError on anything else rather than
mis-matching it. A lister that throws omits its own entries and tells the client
the listing is degraded; it does not fail the whole call.
Prompts #
server.addPrompt(Prompt(
name: 'review',
arguments: const [PromptArgument(name: 'path', required: true)],
completer: (request, context) async =>
CompleteResult(await pathsStartingWith(request.argumentValue)),
handler: (arguments, context) => GetPromptResult(
messages: [PromptMessage.user('Review ${arguments['path']}')],
),
));
Required arguments are validated before the handler runs. Registering a
completer on a prompt or resource template is what makes the server advertise
the completions capability.
Calling back into the client #
When the client advertises the matching capability, the server can make requests
of its own. Each call throws a StateError if the capability is absent.
createMessage, listRoots, and elicit give up after five minutes by default
— generous, because a human may be sitting behind them, but bounded, because a
client that never answers would otherwise pin the handler and its connection for
the life of the session. ping defaults to 30 seconds. Pass timeout: null to
wait forever, or your own Duration.
if (server.clientCapabilities?.sampling ?? false) {
final reply = await server.createMessage(CreateMessageRequest(
messages: [SamplingMessage.user('Summarize this changelog')],
maxTokens: 512,
modelPreferences: const ModelPreferences(hints: [ModelHint('sonnet')]),
));
print(reply.text);
}
final roots = await server.listRoots();
final answer = await server.elicit(
message: 'Which environment should I deploy to?',
requestedSchema: {
'type': 'object',
'properties': {'environment': {'type': 'string', 'enum': ['staging', 'prod']}},
},
);
if (answer.accepted) deploy(answer.content!['environment'] as String);
Progress, cancellation, and logging #
Every handler receives a RequestContext:
reportProgress(...)sends a progress notification, and does nothing when the client did not supply a progress token.isCancelled,onCancelled, andthrowIfCancelled()observe cancellation, which covers bothnotifications/cancelledand the connection closing. A cancelled request receives no response, per the specification;initializeis the exception, since the session depends on its result.log(level, data)sends anotifications/message, dropped automatically if it is below the level the client set withlogging/setLevel.
Pagination #
List results are unpaginated by default. Pass pageSize to page them; the server
issues opaque cursors and validates the ones it gets back, including the
collection they were issued for — a tools/list cursor replayed against
prompts/list is rejected. Cursors encode an offset into the registry as it
stood when the page was produced, so keep it stable while a client pages through
it, or send the matching list_changed notification and let the client start
over.
McpServer(name: 'big', version: '1.0.0', pageSize: 50);
Security #
The defaults are conservative, and they matter because a local MCP server is reachable from any web page the user has open:
- Binds to loopback unless you pass an
address. - Requests carrying an
Originheader are rejected unless the origin is loopback, and binding to a non-loopbackaddressrequires an explicitallowedOrigins. That is a DNS-rebinding control, not authorization: it constrains browsers, and when an allowlist is set a request with noOriginat all is refused too. Note also that the loopback default trusts every page served from localhost, whatever its port — including a dev server you did not write. - Only an
initializerequest may open a session, so an unauthenticated caller cannot allocate servers by looping on any other method.maxSessionscaps how many may be live at once, andmaxRequestBytescaps how large a POST body may be (413beyond that). - Sessions are reclaimed after
sessionIdleTimeoutof client silence (30 min) and atsessionMaxLifetimeregardless (12 h). An attached SSE stream is not liveness — a peer can park one open and go quiet — so only inbound traffic resets the clock.maxRequestStreams(64) caps in-flight requests per session. Every limit and its default is documented onMcpHttpServer.bind; they are deliberately in one place so they cannot drift. Acceptis parsed as media ranges with RFC 9110 precedence, sotext/*;q=0, */*is a rejection rather than a wildcard acceptance.- Errors sent to the peer carry no exception text, stack trace, or path; the detail goes to stderr.
- Pass a
SecurityContextto serve HTTPS directly, or terminate TLS upstream. - This package does not implement OAuth. If you expose a server beyond localhost, authenticate and authorize in front of it.
await McpHttpServer.bind(
onSession: buildServer,
address: InternetAddress.anyIPv4,
port: 443,
allowedOrigins: {'https://app.example.com'},
sessionIdleTimeout: const Duration(minutes: 30),
securityContext: SecurityContext()
..useCertificateChain('fullchain.pem')
..usePrivateKey('privkey.pem'),
);
Embedding in an existing HTTP stack #
McpHttpServer owns the whole endpoint. To route MCP inside a server you
already have, drive SseTransport yourself — readJsonRpcPost and
writeJsonRpcError are exported for exactly this:
final transport = SseTransport(sessionId: mySessionId);
await server.connect(transport);
// POST: decode the body yourself if you need to gate on it, or let the
// transport read it.
await transport.handlePost(request);
// GET: attaches the standalone SSE stream and returns when it closes.
await transport.handleGet(request);
You are then responsible for what McpHttpServer does around the transport:
origin checking, session lookup, the initialize-only gate on session
creation, and the resource limits.
Known deviations #
Two places where this package knowingly departs from the specification, both because the alternative would be worse:
structuredContentis not validated againstoutputSchema. The server checks only that a tool declaring an output schema returned something. Adding a JSON Schema validator would mean a dependency this package does not otherwise need; clients validate anyway.- A missing
MCP-Protocol-Versionheader is read as2025-06-18. The spec says to assume2025-03-26, but this package implements one revision, so honouring that would reject every header-less client. An explicit header with any other value is still a400. - A missing
clientInfo.versionis filled in as0.0.0. The field is required, but several clients omit it and refusing the handshake over a cosmetic field helps nobody.McpServer.clientInfo.versiontherefore cannot distinguish "not reported" from a client that genuinely reports0.0.0.
Testing your server #
MemoryTransport.pair() connects a server to an in-process client with no
sockets or serialization involved:
final (serverSide, clientSide) = MemoryTransport.pair();
await server.connect(serverSide);
final replies = clientSide.messages.where((m) => m is JsonRpcResponse);
await clientSide.send(JsonRpcRequest(id: 1, method: 'tools/list'));
print(((await replies.first) as JsonRpcResponse).result);
messages is single-subscription — exactly one listener, ever. It buffers
events emitted before that listener attaches, so the order above is a
readability preference rather than a requirement.
Development #
dart pub get
dart analyze
dart test
dart run example/example.dart
License #
MIT — see LICENSE.