blob: 9b45b8ee50b4b9c38e72117ef014d1b5b7768163 [file]
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// One [MCPServer] subclass, serving one tool to 2026-07-28 clients and to
/// clients on the revisions before it.
///
/// With no arguments it serves stdio, where
/// `example/multi_version_client.dart` drives it. With `--http` it serves the
/// same class over Streamable HTTP, the transport 2026-07-28 added, and prints
/// two `curl` commands. The tool answers with an [InputRequiredResult] either
/// way; on stdio this package sends that as the `elicitation/create` an older
/// client has.
library;
import 'dart:io' as io;
import 'package:dart_mcp/server.dart';
import 'package:dart_mcp/stdio.dart';
import 'package:dart_mcp/streamable_http.dart';
void main(List<String> args) async {
if (args.contains('--http')) return _serveStreamableHttp();
// One long-lived server for the one connection stdio has.
MCPServerWithInputRequired(stdioChannel(input: io.stdin, output: io.stdout));
}
/// Serves [MCPServerWithInputRequired] over Streamable HTTP on a free port.
///
/// `example/streamable_http_server.dart` is the example for this transport and
/// explains why the host checks the path and the `Origin` header itself.
Future<void> _serveStreamableHttp() async {
const path = '/mcp';
final server = await io.HttpServer.bind(io.InternetAddress.loopbackIPv4, 0);
final endpoint = 'http://${server.address.host}:${server.port}$path';
server.listen((request) async {
// The handler reads no path; the one this server answers on is the host's
// to choose and to enforce.
if (request.uri.path != path) {
request.response
..statusCode = io.HttpStatus.notFound
..contentLength = 0;
await request.response.close();
return;
}
// No `Origin` is valid here: nothing in this example is meant to be
// driven from a page. The peer example explains what that check is for.
if (request.headers['origin'] != null) {
request.response
..statusCode = io.HttpStatus.forbidden
..contentLength = 0;
await request.response.close();
return;
}
try {
// Every POST gets its own server. The tool keeps nothing between its
// two calls; the second one carries the answer.
await handleStreamableHttpRequest(
request,
MCPServerWithInputRequired.new,
);
} on Exception catch (error) {
// An `Error` is a bug in this example, and letting it crash says so.
io.stderr.writeln('request failed: $error');
}
});
print(
'''
Listening on $endpoint
# `greet` asks who to greet. This first call answers `input_required`.
${_callGreet(endpoint, 1, '')}
# The client answers and calls again, under the key the result asked on. The
# retry is an independent request, so it carries a new `id`. A stdio client is
# asked for the same thing as an `elicitation/create` request.
${_callGreet(endpoint, 2, '\n "inputResponses": {"name": $_accepted},')}''',
);
}
/// An accepted form elicitation, as a client sends one back.
const _accepted = '{"action": "accept", "content": {"name": "world"}}';
/// The `curl` command calling `greet` on [endpoint] as [id], with
/// [inputResponses].
///
/// The `_meta` envelope replaces the `initialize` handshake on this revision,
/// and the capabilities it carries have to cover what the tool asks for.
String _callGreet(String endpoint, int id, String inputResponses) => '''
curl -sS $endpoint \\
-H 'Content-Type: application/json' \\
-H 'Accept: application/json, text/event-stream' \\
-H 'MCP-Protocol-Version: 2026-07-28' \\
-H 'Mcp-Method: tools/call' -H 'Mcp-Name: greet' \\
-d '{
"jsonrpc": "2.0", "id": $id, "method": "tools/call",
"params": {
"name": "greet",$inputResponses
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "0"},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": {"form": {}}
}
}
}
}'
''';
/// A server with one tool that needs a value from the user before it can
/// answer.
///
/// The tool is written once, for 2026-07-28, and never reads
/// [MCPServer.protocolVersion].
base class MCPServerWithInputRequired extends MCPServer with ToolsSupport {
MCPServerWithInputRequired(super.channel)
: super.fromStreamChannel(
implementation: Implementation(
name: 'An example dart server which serves several revisions',
version: '0.1.0',
),
instructions: 'Call `greet` and answer what it asks for',
) {
registerTool(greetTool, _greet);
}
/// A tool that greets a name it asks the client to collect.
final greetTool = Tool(
name: 'greet',
description: 'greets a name it asks the client to collect',
inputSchema: Schema.object(),
);
/// The implementation of the `greet` tool, asking for a name on the first
/// call and greeting the answer the second call carries.
CallToolResponse _greet(CallToolRequest request) {
// The first call answers with an input request. The retry arrives with the
// client's answer under the same key, and reading that key here is what
// separates the two rounds.
// https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr
if (request.elicitResult('name') case final answer?) {
// `decline` and `cancel` both leave the tool without a name, as does an
// `accept` that carries no content.
final content =
answer.action == ElicitationAction.accept ? answer.content : null;
final name = content?['name'];
return CallToolResult(
content: [
Content.text(
text: name == null ? 'Nothing to greet.' : 'Hello, $name!',
),
],
isError: name == null,
);
}
final askForAName = ElicitRequest.form(
message: 'Who should I greet?',
requestedSchema: Schema.object(
properties: {'name': Schema.string()},
required: ['name'],
),
);
return InputRequiredResult(
inputRequests: {'name': InputRequest.elicit(askForAName)},
);
}
}