blob: ccc1113b427eda7300f7f05114cb98f687b8008a [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.
import 'dart:convert';
import 'package:dart_mcp/client.dart';
import 'package:dart_mcp/src/utils/constants.dart';
import 'package:test/test.dart';
void main() {
group('InputRequest', () {
test('writes the method next to the request it applies to', () {
final sample = CreateMessageRequest(messages: [], maxTokens: 1);
expect(InputRequest.sample(sample) as Map<String, Object?>, {
'method': 'sampling/createMessage',
'params': sample,
});
final elicit = ElicitRequest.form(
message: 'What is your name?',
requestedSchema: ObjectSchema(
properties: {'name': StringSchema()},
required: ['name'],
),
);
expect(InputRequest.elicit(elicit) as Map<String, Object?>, {
'method': 'elicitation/create',
'params': elicit,
});
final roots = ListRootsRequest();
expect(InputRequest.listRoots(roots) as Map<String, Object?>, {
'method': 'roots/list',
'params': roots,
});
});
test('reads back a request a server sent', () {
final request = InputRequest.fromMap({
'method': 'roots/list',
'params': <String, Object?>{},
});
expect(request.method, 'roots/list');
expect(request.params as Map<String, Object?>?, isEmpty);
});
test('tells the kinds apart by method', () {
final elicit = InputRequest.elicit(
ElicitRequest.form(
message: 'What is your name?',
requestedSchema: ObjectSchema(
properties: {'name': StringSchema()},
required: ['name'],
),
),
);
final sample = InputRequest.sample(
CreateMessageRequest(messages: [], maxTokens: 1),
);
final roots = InputRequest.listRoots(ListRootsRequest());
expect(
[elicit.isElicit, elicit.isSample, elicit.isListRoots],
[true, false, false],
);
expect(
[sample.isElicit, sample.isSample, sample.isListRoots],
[false, true, false],
);
expect(
[roots.isElicit, roots.isSample, roots.isListRoots],
[false, false, true],
);
});
test('rejects a request with no method', () {
expect(
() => InputRequest.fromMap({'params': <String, Object?>{}}),
throwsA(isA<AssertionError>()),
);
// A compiled executable has asserts stripped, so there is nothing to
// catch there.
}, testOn: '!exe');
});
group('InputRequiredResult', () {
test('writes only the fields it is given', () {
expect(
InputRequiredResult(requestState: 'opaque') as Map<String, Object?>,
{'resultType': 'input_required', 'requestState': 'opaque'},
);
expect(
InputRequiredResult(
inputRequests: {
'roots': InputRequest.listRoots(ListRootsRequest()),
},
)
as Map<String, Object?>,
{
'resultType': 'input_required',
'inputRequests': {
'roots': {'method': 'roots/list', 'params': <String, Object?>{}},
},
},
);
});
test('writes a padded request state unchanged', () {
const padded = ' client.state/+ ';
final result = InputRequiredResult(requestState: padded);
expect((result as Map<String, Object?>)[Keys.requestState], padded);
});
test('reads back the requests a server sent', () {
final result = InputRequiredResult.fromMap({
'resultType': 'input_required',
'inputRequests': {
'roots': {'method': 'roots/list', 'params': <String, Object?>{}},
},
'requestState': 'opaque',
});
expect(result.inputRequests, hasLength(1));
expect(result.inputRequests!['roots']!.method, 'roots/list');
expect(result.requestState, 'opaque');
});
test('leaves both fields null when a server omits them', () {
final result = InputRequiredResult.fromMap({
'resultType': 'input_required',
});
expect(result.inputRequests, isNull);
expect(result.requestState, isNull);
});
test('writes the metadata it is given', () {
expect(
InputRequiredResult(
requestState: 'opaque',
meta: Meta.fromMap({'io.modelcontextprotocol/serverInfo': {}}),
)
as Map<String, Object?>,
containsPair(
'_meta',
containsPair('io.modelcontextprotocol/serverInfo', isEmpty),
),
);
});
test('tells no kind apart when an entry has no method', () {
final result = InputRequiredResult.fromMap({
'resultType': 'input_required',
'inputRequests': {'a': <String, Object?>{}},
});
final entry = result.inputRequests!['a']!;
expect(entry.isElicit, isFalse);
expect(entry.isSample, isFalse);
expect(entry.isListRoots, isFalse);
});
test('rejects a result which asks for nothing', () {
expect(InputRequiredResult.new, throwsA(isA<AssertionError>()));
expect(
() => InputRequiredResult(inputRequests: {}),
throwsA(isA<AssertionError>()),
);
}, testOn: '!exe');
test('writes an empty inputRequests next to a request state', () {
expect(
InputRequiredResult(inputRequests: {}, requestState: 'opaque')
as Map<String, Object?>,
{
'resultType': 'input_required',
'inputRequests': <String, InputRequest>{},
'requestState': 'opaque',
},
);
});
});
group('WithInputResponses', () {
final elicited = ElicitResult(action: ElicitationAction.accept);
final sampled = CreateMessageResult(
role: Role.assistant,
content: [Content.text(text: 'Paris')],
model: 'a-model',
);
test('the retry carries what the client gathered', () {
final request = CallToolRequest(
name: 'deploy',
inputResponses: {'github_login': elicited, 'capital': sampled},
requestState: 'an opaque blob',
);
expect(request as Map<String, Object?>, {
'name': 'deploy',
'inputResponses': {'github_login': elicited, 'capital': sampled},
'requestState': 'an opaque blob',
});
});
test('keeps untrusted requestState unchanged after JSON decoding', () {
final wire =
jsonDecode('{"uri":"file:///a","requestState":" client.state/+ "}')
as Map<String, Object?>;
expect(
ReadResourceRequest.fromMap(wire).requestState,
' client.state/+ ',
);
});
test('keeps padded requestState unchanged in the JSON it writes', () {
const padded = ' client.state/+ ';
for (var request in <WithInputResponses>[
CallToolRequest(name: 'deploy', requestState: padded),
GetPromptRequest(name: 'review', requestState: padded),
ReadResourceRequest(uri: 'file:///a', requestState: padded),
]) {
final wire = jsonDecode(jsonEncode(request)) as Map<String, Object?>;
expect(wire[Keys.requestState], padded);
}
});
test('a first attempt writes neither field', () {
expect(CallToolRequest(name: 'deploy') as Map<String, Object?>, {
'name': 'deploy',
});
expect(GetPromptRequest(name: 'review') as Map<String, Object?>, {
'name': 'review',
});
expect(ReadResourceRequest(uri: 'file:///a') as Map<String, Object?>, {
'uri': 'file:///a',
});
});
test('a server reads each response back as what it asked for', () {
final request = GetPromptRequest.fromMap({
'name': 'review',
'inputResponses': {'github_login': elicited, 'capital': sampled},
});
expect(
(request.inputResponses!['github_login']! as ElicitResult).action,
ElicitationAction.accept,
);
expect(
(request.inputResponses!['capital']! as CreateMessageResult).model,
'a-model',
);
});
test('elicitResult returns null when the key is absent', () {
final request = GetPromptRequest.fromMap({
'name': 'review',
'inputResponses': {'github_login': elicited},
});
expect(
request.elicitResult('github_login')!.action,
ElicitationAction.accept,
);
expect(request.elicitResult('missing'), isNull);
expect(
GetPromptRequest(name: 'review').elicitResult('github_login'),
isNull,
);
});
test('createMessageResult and listRootsResult read the matching arm', () {
final roots = ListRootsResult(roots: [Root(uri: 'file:///a')]);
final request = GetPromptRequest.fromMap({
'name': 'review',
'inputResponses': {'capital': sampled, 'workspace': roots},
});
expect(request.createMessageResult('capital')!.model, 'a-model');
expect(
request.listRootsResult('workspace')!.roots.single.uri,
'file:///a',
);
expect(request.createMessageResult('missing'), isNull);
expect(request.listRootsResult('missing'), isNull);
});
test('a present response missing a required key is an ArgumentError', () {
final request = GetPromptRequest.fromMap({
'name': 'review',
'inputResponses': {
'github_login': <String, Object?>{},
'capital': <String, Object?>{'model': 'a-model'},
'workspace': <String, Object?>{},
},
});
expect(() => request.elicitResult('github_login'), throwsArgumentError);
expect(() => request.createMessageResult('capital'), throwsArgumentError);
expect(() => request.listRootsResult('workspace'), throwsArgumentError);
});
test('writes an empty inputResponses next to a request state', () {
expect(
CallToolRequest(
name: 'deploy',
inputResponses: {},
requestState: 'opaque',
)
as Map<String, Object?>,
{
'name': 'deploy',
'inputResponses': <String, Result>{},
'requestState': 'opaque',
},
);
expect(
GetPromptRequest(
name: 'review',
inputResponses: {},
requestState: 'opaque',
)
as Map<String, Object?>,
{
'name': 'review',
'inputResponses': <String, Result>{},
'requestState': 'opaque',
},
);
expect(
ReadResourceRequest(
uri: 'file:///a',
inputResponses: {},
requestState: 'opaque',
)
as Map<String, Object?>,
{
'uri': 'file:///a',
'inputResponses': <String, Result>{},
'requestState': 'opaque',
},
);
});
test('reads both fields back off all three', () {
for (var request in <WithInputResponses>[
CallToolRequest(
name: 'deploy',
inputResponses: {'github_login': elicited},
requestState: 'state',
),
GetPromptRequest(
name: 'review',
inputResponses: {'github_login': elicited},
requestState: 'state',
),
ReadResourceRequest(
uri: 'file:///a',
inputResponses: {'github_login': elicited},
requestState: 'state',
),
]) {
expect(request.requestState, 'state');
expect(request.inputResponses, {'github_login': elicited});
}
});
test('a request without them reads both as null', () {
final request = ReadResourceRequest.fromMap({'uri': 'file:///a'});
expect(request.inputResponses, isNull);
expect(request.requestState, isNull);
});
});
}