Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@
`cancel`.
- Rejected URL elicitation values that are not absolute URIs to match the stable
and MCP 2026 `format: uri` schemas.
- Rejected non-absolute resource URIs and malformed resource URI templates to
match stable and MCP 2026 `format: uri` and `format: uri-template` schemas.
- Rejected non-finite numeric values for progress, annotation priority, model
priority, and sampling temperature fields so SDK-built payloads remain valid
JSON numbers.
Expand Down
6 changes: 5 additions & 1 deletion example/server_stdio.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ void main() async {
final text = 'Sample log content';
return ReadResourceResult(
contents: [
TextResourceContents(uri: uri.path, mimeType: 'text/plain', text: text),
TextResourceContents(
uri: uri.toString(),
mimeType: 'text/plain',
text: text,
),
],
);
});
Expand Down
31 changes: 23 additions & 8 deletions lib/src/shared/uri_template.dart
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ class UriTemplateExpander {

parts.add(_ExpressionPart(operator, varSpecs));
i = end + 1;
} else if (template[i] == '}') {
throw ArgumentError(
"Unmatched closing template expression at index $i",
);
} else {
currentText.write(template[i]);
i++;
Expand All @@ -153,8 +157,9 @@ class UriTemplateExpander {
}

static List<_VarSpec> _parseVarSpecs(String specsString) {
return specsString.split(',').map((spec) {
spec = spec.trim();
return specsString.split(',').map((rawSpec) {
var spec = rawSpec.trim();
final originalSpec = spec;
bool explode = false;
int? prefix;

Expand All @@ -164,16 +169,20 @@ class UriTemplateExpander {
} else {
final prefixMatch = RegExp(r':(\d+)$').firstMatch(spec);
if (prefixMatch != null) {
prefix = int.tryParse(prefixMatch.group(1)!);
final prefixText = prefixMatch.group(1)!;
if (!RegExp(r'^[1-9][0-9]{0,4}$').hasMatch(prefixText)) {
throw ArgumentError(
"Invalid prefix modifier '$prefixText' in variable spec "
"'$originalSpec'",
);
}
prefix = int.parse(prefixText);
spec = spec.substring(0, prefixMatch.start);
}
}

if (spec.isEmpty ||
!RegExp(r'^[a-zA-Z0-9_]|(%[0-9A-Fa-f]{2})').hasMatch(spec[0])) {
if (spec.isNotEmpty) {
// Allow empty string from splitting trailing comma
}
if (!_isValidVariableName(spec)) {
throw ArgumentError("Invalid variable name '$spec'");
}

_validateLength(spec, maxVariableLength, "Variable name '$spec'");
Expand All @@ -182,6 +191,12 @@ class UriTemplateExpander {
}).toList();
}

static bool _isValidVariableName(String spec) {
return RegExp(
r'^(?:[a-zA-Z0-9_]|%[0-9A-Fa-f]{2})(?:\.?(?:[a-zA-Z0-9_]|%[0-9A-Fa-f]{2}))*$',
).hasMatch(spec);
}

String _encodeValue(String value, String operator) {
_validateLength(value, maxVariableLength, "Variable value");

Expand Down
28 changes: 18 additions & 10 deletions lib/src/types/completion.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,24 @@ sealed class Reference {
};
}

Map<String, dynamic> toJson() => {
'type': type,
...switch (this) {
final ResourceReference r => {'uri': r.uri},
final PromptReference p => {
'name': p.name,
if (p.title != null) 'title': p.title,
},
Map<String, dynamic> toJson() {
return switch (this) {
final ResourceReference r => _resourceReferenceToJson(r),
final PromptReference p => {
'type': p.type,
'name': p.name,
if (p.title != null) 'title': p.title,
},
};
};
}
}

Map<String, dynamic> _resourceReferenceToJson(ResourceReference reference) {
validateUriTemplateString(reference.uri, 'ResourceReference.uri');
return {
'type': reference.type,
'uri': reference.uri,
};
}

/// Reference to a resource or resource template URI.
Expand All @@ -39,7 +47,7 @@ class ResourceReference extends Reference {

factory ResourceReference.fromJson(Map<String, dynamic> json) {
return ResourceReference(
uri: json['uri'] as String,
uri: readRequiredUriTemplateString(json['uri'], 'ResourceReference.uri'),
);
}
}
Expand Down
16 changes: 12 additions & 4 deletions lib/src/types/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ void _validateAbsoluteUriString(String value, String field) {
}
}

String _absoluteUriForJson(String value, String field) {
validateAbsoluteUriString(value, field);
return value;
}

String? _readOptionalPresentString(
Map<String, dynamic> json,
String key,
Expand Down Expand Up @@ -146,7 +151,10 @@ sealed class ResourceContents {

/// Creates a specific [ResourceContents] subclass from JSON.
factory ResourceContents.fromJson(Map<String, dynamic> json) {
final uri = json['uri'] as String;
final uri = readRequiredAbsoluteUriString(
json['uri'],
'ResourceContents.uri',
);
final mimeType = json['mimeType'] as String?;
final meta = _asJsonObjectOrNull(
json['_meta'],
Expand Down Expand Up @@ -193,7 +201,7 @@ sealed class ResourceContents {

/// Converts resource contents to JSON.
Map<String, dynamic> toJson() => {
'uri': uri,
'uri': _absoluteUriForJson(uri, 'ResourceContents.uri'),
if (mimeType != null) 'mimeType': mimeType,
...switch (this) {
final TextResourceContents c => {'text': c.text},
Expand Down Expand Up @@ -352,7 +360,7 @@ sealed class Content {
'_meta': readJsonObject(c.meta, 'AudioContent._meta'),
},
final ResourceLink c => {
'uri': c.uri,
'uri': _absoluteUriForJson(c.uri, 'ResourceLink.uri'),
'name': c.name,
if (c.title != null) 'title': c.title,
if (c.description != null) 'description': c.description,
Expand Down Expand Up @@ -569,7 +577,7 @@ class ResourceLink extends Content {

factory ResourceLink.fromJson(Map<String, dynamic> json) {
return ResourceLink(
uri: json['uri'] as String,
uri: readRequiredAbsoluteUriString(json['uri'], 'ResourceLink.uri'),
name: json['name'] as String,
title: json['title'] as String?,
description: json['description'] as String?,
Expand Down
114 changes: 75 additions & 39 deletions lib/src/types/resources.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ class Resource {
/// Creates from JSON.
factory Resource.fromJson(Map<String, dynamic> json) {
return Resource(
uri: json['uri'] as String,
uri: readRequiredAbsoluteUriString(json['uri'], 'Resource.uri'),
name: json['name'] as String,
title: json['title'] as String?,
description: json['description'] as String?,
Expand All @@ -122,18 +122,20 @@ class Resource {
}

/// Converts to JSON.
Map<String, dynamic> toJson() => {
'uri': uri,
'name': name,
if (title != null) 'title': title,
if (description != null) 'description': description,
if (mimeType != null) 'mimeType': mimeType,
if (icons != null)
'icons': icons!.map((icon) => icon.toJson()).toList(),
if (size != null) 'size': size,
if (annotations != null) 'annotations': annotations!.toJson(),
if (meta != null) '_meta': readJsonObject(meta, 'Resource._meta'),
};
Map<String, dynamic> toJson() {
validateAbsoluteUriString(uri, 'Resource.uri');
return {
'uri': uri,
'name': name,
if (title != null) 'title': title,
if (description != null) 'description': description,
if (mimeType != null) 'mimeType': mimeType,
if (icons != null) 'icons': icons!.map((icon) => icon.toJson()).toList(),
if (size != null) 'size': size,
if (annotations != null) 'annotations': annotations!.toJson(),
if (meta != null) '_meta': readJsonObject(meta, 'Resource._meta'),
};
}
}

/// A template description for resources available on the server.
Expand Down Expand Up @@ -184,7 +186,10 @@ class ResourceTemplate {
/// Creates from JSON.
factory ResourceTemplate.fromJson(Map<String, dynamic> json) {
return ResourceTemplate(
uriTemplate: json['uriTemplate'] as String,
uriTemplate: readRequiredUriTemplateString(
json['uriTemplate'],
'ResourceTemplate.uriTemplate',
),
name: json['name'] as String,
title: json['title'] as String?,
description: json['description'] as String?,
Expand All @@ -205,18 +210,22 @@ class ResourceTemplate {
}

/// Converts to JSON.
Map<String, dynamic> toJson() => {
'uriTemplate': uriTemplate,
'name': name,
if (title != null) 'title': title,
if (description != null) 'description': description,
if (mimeType != null) 'mimeType': mimeType,
if (icons != null)
'icons': icons!.map((icon) => icon.toJson()).toList(),
if (annotations != null) 'annotations': annotations!.toJson(),
if (meta != null)
'_meta': readJsonObject(meta, 'ResourceTemplate._meta'),
};
Map<String, dynamic> toJson() {
validateUriTemplateString(
uriTemplate,
'ResourceTemplate.uriTemplate',
);
return {
'uriTemplate': uriTemplate,
'name': name,
if (title != null) 'title': title,
if (description != null) 'description': description,
if (mimeType != null) 'mimeType': mimeType,
if (icons != null) 'icons': icons!.map((icon) => icon.toJson()).toList(),
if (annotations != null) 'annotations': annotations!.toJson(),
if (meta != null) '_meta': readJsonObject(meta, 'ResourceTemplate._meta'),
};
}
}

/// Parameters for the `resources/list` request. Includes pagination.
Expand Down Expand Up @@ -461,7 +470,10 @@ class ReadResourceRequest {

factory ReadResourceRequest.fromJson(Map<String, dynamic> json) =>
ReadResourceRequest(
uri: json['uri'] as String,
uri: readRequiredAbsoluteUriString(
json['uri'],
'ReadResourceRequest.uri',
),
inputResponses: InputResponse.mapFromJson(
json['inputResponses'],
'ReadResourceRequest.inputResponses',
Expand All @@ -472,12 +484,15 @@ class ReadResourceRequest {
),
);

Map<String, dynamic> toJson() => {
'uri': uri,
if (inputResponses != null)
'inputResponses': InputResponse.mapToJson(inputResponses!),
if (requestState != null) 'requestState': requestState,
};
Map<String, dynamic> toJson() {
validateAbsoluteUriString(uri, 'ReadResourceRequest.uri');
return {
'uri': uri,
if (inputResponses != null)
'inputResponses': InputResponse.mapToJson(inputResponses!),
if (requestState != null) 'requestState': requestState,
};
}
}

/// Request sent from client to read a specific resource.
Expand Down Expand Up @@ -583,9 +598,14 @@ class SubscribeRequest {
const SubscribeRequest({required this.uri});

factory SubscribeRequest.fromJson(Map<String, dynamic> json) =>
SubscribeRequest(uri: json['uri'] as String);
SubscribeRequest(
uri: readRequiredAbsoluteUriString(json['uri'], 'SubscribeRequest.uri'),
);

Map<String, dynamic> toJson() => {'uri': uri};
Map<String, dynamic> toJson() {
validateAbsoluteUriString(uri, 'SubscribeRequest.uri');
return {'uri': uri};
}
}

/// Request sent from client to subscribe to updates for a resource.
Expand Down Expand Up @@ -621,9 +641,17 @@ class UnsubscribeRequest {
const UnsubscribeRequest({required this.uri});

factory UnsubscribeRequest.fromJson(Map<String, dynamic> json) =>
UnsubscribeRequest(uri: json['uri'] as String);
UnsubscribeRequest(
uri: readRequiredAbsoluteUriString(
json['uri'],
'UnsubscribeRequest.uri',
),
);

Map<String, dynamic> toJson() => {'uri': uri};
Map<String, dynamic> toJson() {
validateAbsoluteUriString(uri, 'UnsubscribeRequest.uri');
return {'uri': uri};
}
}

/// Request sent from client to cancel a resource subscription.
Expand Down Expand Up @@ -661,9 +689,17 @@ class ResourceUpdatedNotification {
factory ResourceUpdatedNotification.fromJson(
Map<String, dynamic> json,
) =>
ResourceUpdatedNotification(uri: json['uri'] as String);
ResourceUpdatedNotification(
uri: readRequiredAbsoluteUriString(
json['uri'],
'ResourceUpdatedNotification.uri',
),
);

Map<String, dynamic> toJson() => {'uri': uri};
Map<String, dynamic> toJson() {
validateAbsoluteUriString(uri, 'ResourceUpdatedNotification.uri');
return {'uri': uri};
}
}

/// Notification from server indicating a subscribed resource has changed.
Expand Down
Loading