blob: e2d9d185e69ca293b334b094f677a94cd8612481 [file]
// Copyright (c) 2025, 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:async';
import 'dart:convert';
import 'package:dart_mcp/server.dart';
import 'package:dart_mcp/src/client/client.dart';
import 'package:test/test.dart';
import '../test_utils.dart';
void main() {
group('tool content on the wire', () {
test('ToolUseContent survives a JSON round trip', () {
final content = ToolUseContent(
id: 'call-1',
name: 'lookup',
input: {'city': 'Ankara'},
);
final decoded = jsonDecode(jsonEncode(content)) as Map<String, Object?>;
final parsed = SamplingMessageContentBlock.fromMap(decoded);
expect(parsed.isToolUse, isTrue);
expect(parsed.isToolResult, isFalse);
expect((parsed as ToolUseContent).input, {'city': 'Ankara'});
expect(parsed.name, 'lookup');
});
test('ToolResultContent survives a JSON round trip', () {
final content = ToolResultContent(
content: [TextContent(text: 'sunny')],
toolUseId: 'call-1',
isError: false,
);
final decoded = jsonDecode(jsonEncode(content)) as Map<String, Object?>;
final parsed = SamplingMessageContentBlock.fromMap(decoded);
expect(parsed.isToolResult, isTrue);
expect(parsed.isToolUse, isFalse);
expect((parsed as ToolResultContent).toolUseId, 'call-1');
expect((parsed.content.single as TextContent).text, 'sunny');
expect(parsed.isError, isFalse);
});
test('a sampling message carries tool use content', () {
final message = SamplingMessage(
role: Role.assistant,
content: [
SamplingMessageContentBlock.toolUse(
id: 'call-1',
name: 'lookup',
input: {},
),
],
);
final decoded = jsonDecode(jsonEncode(message)) as Map<String, Object?>;
final parsed = SamplingMessage.fromMap(decoded);
expect(parsed.content.single.isToolUse, isTrue);
expect((parsed.content.single as ToolUseContent).id, 'call-1');
});
test('text content is neither tool use nor tool result', () {
final content = SamplingMessageContentBlock.text(text: 'hi');
expect(content.isToolUse, isFalse);
expect(content.isToolResult, isFalse);
});
test('SamplingMessage round trips text content through the union', () {
final message = SamplingMessage(
role: Role.user,
content: [SamplingMessageContentBlock.text(text: 'merhaba')],
);
final decoded = jsonDecode(jsonEncode(message)) as Map<String, Object?>;
final parsed = SamplingMessage.fromMap(decoded);
expect(parsed.content.single.type, TextContent.expectedType);
expect((parsed.content.single as TextContent).text, 'merhaba');
});
test('SamplingMessage throws when content is missing', () {
expect(
() => SamplingMessage.fromMap({'role': 'user'}).content,
throwsArgumentError,
);
});
test('CreateMessageResult accepts a SamplingMessageContentBlock', () {
final block = SamplingMessageContentBlock.toolResult(
toolUseId: 'call-2',
content: [TextContent(text: 'ok')],
);
final result = CreateMessageResult(
role: Role.assistant,
content: [block],
model: 'fakeModel',
);
expect(result.content.single.isToolResult, isTrue);
expect((result.content.single as ToolResultContent).toolUseId, 'call-2');
});
test('tools reads as null when the key is absent', () {
final request = CreateMessageRequest(messages: [], maxTokens: 1);
expect(request.tools, isNull);
});
});
group('content as one block or a list', () {
test('a list on the wire reads as every block in it', () {
final message = SamplingMessage.fromMap({
'role': 'assistant',
'content': [
{'type': 'text', 'text': 'first'},
{
'type': 'tool_use',
'id': 'call-1',
'name': 'lookup',
'input': <String, Object?>{},
},
],
});
expect(message.content, hasLength(2));
expect((message.content.first as TextContent).text, 'first');
expect(message.content.last.isToolUse, isTrue);
});
test('a bare block on the wire reads as one block', () {
final message = SamplingMessage.fromMap({
'role': 'user',
'content': {'type': 'text', 'text': 'only'},
});
expect(message.content, hasLength(1));
expect((message.content.single as TextContent).text, 'only');
});
test('one block goes on the wire as that block', () {
final message = SamplingMessage(
role: Role.user,
content: [TextContent(text: 'only')],
);
final decoded = jsonDecode(jsonEncode(message)) as Map<String, Object?>;
expect(decoded['content'], isA<Map<String, Object?>>());
expect(SamplingMessage.fromMap(decoded).content, hasLength(1));
});
test('two blocks go on the wire as a list', () {
final message = SamplingMessage(
role: Role.assistant,
content: [TextContent(text: 'first'), TextContent(text: 'second')],
);
final decoded = jsonDecode(jsonEncode(message)) as Map<String, Object?>;
expect(decoded['content'], isA<List<Object?>>());
final parsed = SamplingMessage.fromMap(decoded);
expect(parsed.content, hasLength(2));
expect((parsed.content.last as TextContent).text, 'second');
});
test('a result reads a list the same way a message does', () {
final result = CreateMessageResult.fromMap({
'role': 'assistant',
'model': 'a-model',
'content': [
{'type': 'text', 'text': 'one'},
{'type': 'text', 'text': 'two'},
],
});
expect(result.content, hasLength(2));
expect((result.content.first as TextContent).text, 'one');
});
});
group('CreateMessageRequest tools', () {
test('writes and reads tools', () {
final request = CreateMessageRequest(
messages: [],
maxTokens: 1,
tools: [Tool(name: 'x', inputSchema: ObjectSchema())],
);
final wire = request as Map<String, Object?>;
expect(request.tools!.single.name, 'x');
expect(wire['tools'], hasLength(1));
});
test('omits tools when not provided', () {
final wire =
CreateMessageRequest(messages: [], maxTokens: 1)
as Map<String, Object?>;
expect(wire.containsKey('tools'), isFalse);
});
});
test('includeContext round trips the values the schema names', () {
for (final (value, wire) in const [
(IncludeContext.none, 'none'),
(IncludeContext.thisServer, 'thisServer'),
(IncludeContext.allServers, 'allServers'),
]) {
final sent =
CreateMessageRequest(
messages: [],
maxTokens: 1,
includeContext: value,
)
as Map<String, Object?>;
expect(sent['includeContext'], wire);
expect(
CreateMessageRequest.fromMap({
'messages': <Object?>[],
'maxTokens': 1,
'includeContext': wire,
}).includeContext,
value,
);
}
});
test('includeContext reads the name this package used to send', () {
expect(
CreateMessageRequest.fromMap({
'messages': <Object?>[],
'maxTokens': 1,
'includeContext': 'thisService',
}).includeContext,
IncludeContext.thisServer,
);
});
test('model preferences read an integer priority as a double', () {
final prefs = ModelPreferences.fromMap({
'costPriority': 1,
'speedPriority': 0,
'intelligencePriority': 1,
});
expect(prefs.costPriority, 1.0);
expect(prefs.speedPriority, 0.0);
expect(prefs.intelligencePriority, 1.0);
});
test('model preferences leave an absent priority null', () {
final prefs = ModelPreferences.fromMap({});
expect(prefs.costPriority, isNull);
expect(prefs.speedPriority, isNull);
expect(prefs.intelligencePriority, isNull);
});
test('model preferences read a fractional priority', () {
final prefs = ModelPreferences.fromMap({
'costPriority': 0.3,
'speedPriority': 0.8,
'intelligencePriority': 0.5,
});
expect(prefs.costPriority, 0.3);
expect(prefs.speedPriority, 0.8);
expect(prefs.intelligencePriority, 0.5);
});
test('temperature takes a fractional value', () {
expect(
CreateMessageRequest(
messages: [],
maxTokens: 1,
temperature: 0.7,
).temperature,
0.7,
);
});
test('temperature is null when the map leaves it out', () {
expect(
CreateMessageRequest.fromMap({
'messages': <Object?>[],
'maxTokens': 1,
}).temperature,
isNull,
);
});
test('temperature reads an integer as a double', () {
expect(
CreateMessageRequest.fromMap({
'messages': <Object?>[],
'maxTokens': 1,
'temperature': 1,
}).temperature,
1.0,
);
});
group('ToolUseContent', () {
test('round trips through a map', () {
final content = ToolUseContent(
id: 'call-1',
name: 'lookup',
input: {'query': 'weather'},
);
final wire = content as Map<String, Object?>;
expect(wire, {
'type': 'tool_use',
'id': 'call-1',
'name': 'lookup',
'input': {'query': 'weather'},
});
final decoded = ToolUseContent.fromMap(wire);
expect(decoded.type, wire['type']);
expect(decoded.id, 'call-1');
expect(decoded.name, 'lookup');
expect(decoded.input, {'query': 'weather'});
final asContent = SamplingMessageContentBlock.fromMap(wire);
expect(asContent.isToolUse, isTrue);
expect(asContent.isToolResult, isFalse);
});
test('writes metadata when provided', () {
final meta = Meta.fromMap({'source': 'tool'});
final content = ToolUseContent(
id: 'call-1',
name: 'lookup',
input: {},
meta: meta,
);
expect(content as Map<String, Object?>, {
'type': 'tool_use',
'id': 'call-1',
'name': 'lookup',
'input': <String, Object?>{},
'_meta': {'source': 'tool'},
});
expect(
ToolUseContent.fromMap(content as Map<String, Object?>).meta,
meta,
);
});
});
group('ToolResultContent', () {
test('round trips through a map', () {
final text = TextContent(text: 'done');
final result = ToolResultContent(toolUseId: 'call-1', content: [text]);
final wire = result as Map<String, Object?>;
expect(wire, {
'type': 'tool_result',
'toolUseId': 'call-1',
'content': [text],
});
final decoded = ToolResultContent.fromMap(wire);
expect(decoded.type, wire['type']);
expect(decoded.toolUseId, 'call-1');
expect(decoded.content, hasLength(1));
final decodedContent = decoded.content.single;
expect(decodedContent.isText, isTrue);
expect((decodedContent as TextContent).text, 'done');
expect(decoded.structuredContent, isNull);
expect(decoded.isError, isNull);
final asContent = SamplingMessageContentBlock.fromMap(wire);
expect(asContent.isToolResult, isTrue);
expect(asContent.isToolUse, isFalse);
});
test('omits absent optional fields', () {
final result = ToolResultContent(
toolUseId: 'call-1',
content: [TextContent(text: 'done')],
);
final wire = result as Map<String, Object?>;
expect(wire.keys.toSet(), {'type', 'toolUseId', 'content'});
expect(result.structuredContent, isNull);
expect(result.isError, isNull);
expect(result.meta, isNull);
});
test('reads optional fields when provided', () {
final structuredContent = {'answer': '42'};
final meta = Meta.fromMap({'source': 'tool'});
final result = ToolResultContent(
toolUseId: 'call-1',
content: [TextContent(text: 'done')],
structuredContent: structuredContent,
isError: true,
meta: meta,
);
expect(result as Map<String, Object?>, {
'type': 'tool_result',
'toolUseId': 'call-1',
'content': [TextContent(text: 'done')],
'structuredContent': structuredContent,
'isError': true,
'_meta': {'source': 'tool'},
});
expect(result.structuredContent, structuredContent);
expect(result.isError, isTrue);
expect(result.meta, meta);
});
test('throws when content is missing', () {
expect(
() =>
ToolResultContent.fromMap({
'type': 'tool_result',
'toolUseId': 'call-1',
}).content,
throwsArgumentError,
);
});
});
test('server can request LLM messages from the client', () async {
final environment = TestEnvironment(
SamplingTestMCPClient(),
TestMCPServer.new,
);
await environment.initializeServer();
final server = environment.server;
expect(server.clientCapabilities.sampling, isNotNull);
final client = environment.client;
final expectedResult =
client.nextResult = CreateMessageResult(
role: Role.assistant,
content: [TextContent(text: 'Hello')],
model: 'fakeModel',
);
expect(
await server.sendRequest<CreateMessageResult>(
CreateMessageRequest.methodName,
CreateMessageRequest(messages: [], maxTokens: 100),
),
expectedResult,
);
});
test('reading tool content as plain content is refused', () {
final wire = {
'type': ToolUseContent.expectedType,
'id': 'call-1',
'name': 'greet',
};
expect(() => Content.fromMap(wire), throwsA(isA<AssertionError>()));
expect(SamplingMessageContentBlock.fromMap(wire).isToolUse, isTrue);
});
}
final class SamplingTestMCPClient extends TestMCPClient with SamplingSupport {
/// Must be assign prior to sending a [CreateMessageRequest], and will be used
/// as the response to the next request.
CreateMessageResult? nextResult;
@override
FutureOr<CreateMessageResult> handleCreateMessage(
CreateMessageRequest request,
Implementation serverInfo,
) {
if (nextResult case final result?) {
nextResult = null;
return result;
} else {
throw StateError('Must assign `nextResult` before issuing requests');
}
}
}