mcp_sse_server 0.2.1 copy "mcp_sse_server: ^0.2.1" to clipboard
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-18 only. 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 with 2025-06-18 and 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. MemoryTransport exists for in-process clients and tests, and the Transport interface 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, and throwIfCancelled() observe cancellation, which covers both notifications/cancelled and the connection closing. A cancelled request receives no response, per the specification; initialize is the exception, since the session depends on its result.
  • log(level, data) sends a notifications/message, dropped automatically if it is below the level the client set with logging/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 Origin header are rejected unless the origin is loopback, and binding to a non-loopback address requires an explicit allowedOrigins. That is a DNS-rebinding control, not authorization: it constrains browsers, and when an allowlist is set a request with no Origin at 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 initialize request may open a session, so an unauthenticated caller cannot allocate servers by looping on any other method. maxSessions caps how many may be live at once, and maxRequestBytes caps how large a POST body may be (413 beyond that).
  • Sessions are reclaimed after sessionIdleTimeout of client silence (30 min) and at sessionMaxLifetime regardless (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 on McpHttpServer.bind; they are deliberately in one place so they cannot drift.
  • Accept is parsed as media ranges with RFC 9110 precedence, so text/*;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 SecurityContext to 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:

  • structuredContent is not validated against outputSchema. 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-Version header is read as 2025-06-18. The spec says to assume 2025-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 a 400.
  • A missing clientInfo.version is filled in as 0.0.0. The field is required, but several clients omit it and refusing the handshake over a cosmetic field helps nobody. McpServer.clientInfo.version therefore cannot distinguish "not reported" from a client that genuinely reports 0.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.

0
likes
150
points
25
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

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.

Repository (GitHub)
View/report issues

Topics

#mcp #json-rpc #server #sse

License

MIT (license)

Dependencies

meta

More

Packages that depend on mcp_sse_server