diff --git a/CHANGELOG.md b/CHANGELOG.md index a02bd54a..a6ade773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/example/server_stdio.dart b/example/server_stdio.dart index d2c38f07..1434cd3c 100644 --- a/example/server_stdio.dart +++ b/example/server_stdio.dart @@ -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, + ), ], ); }); diff --git a/lib/src/shared/uri_template.dart b/lib/src/shared/uri_template.dart index 77bdc112..368a59f9 100644 --- a/lib/src/shared/uri_template.dart +++ b/lib/src/shared/uri_template.dart @@ -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++; @@ -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; @@ -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'"); @@ -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"); diff --git a/lib/src/types/completion.dart b/lib/src/types/completion.dart index 17fdd345..57ceb848 100644 --- a/lib/src/types/completion.dart +++ b/lib/src/types/completion.dart @@ -19,16 +19,24 @@ sealed class Reference { }; } - Map 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 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 _resourceReferenceToJson(ResourceReference reference) { + validateUriTemplateString(reference.uri, 'ResourceReference.uri'); + return { + 'type': reference.type, + 'uri': reference.uri, + }; } /// Reference to a resource or resource template URI. @@ -39,7 +47,7 @@ class ResourceReference extends Reference { factory ResourceReference.fromJson(Map json) { return ResourceReference( - uri: json['uri'] as String, + uri: readRequiredUriTemplateString(json['uri'], 'ResourceReference.uri'), ); } } diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index dfda826e..b818711e 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -46,6 +46,11 @@ void _validateAbsoluteUriString(String value, String field) { } } +String _absoluteUriForJson(String value, String field) { + validateAbsoluteUriString(value, field); + return value; +} + String? _readOptionalPresentString( Map json, String key, @@ -146,7 +151,10 @@ sealed class ResourceContents { /// Creates a specific [ResourceContents] subclass from JSON. factory ResourceContents.fromJson(Map 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'], @@ -193,7 +201,7 @@ sealed class ResourceContents { /// Converts resource contents to JSON. Map toJson() => { - 'uri': uri, + 'uri': _absoluteUriForJson(uri, 'ResourceContents.uri'), if (mimeType != null) 'mimeType': mimeType, ...switch (this) { final TextResourceContents c => {'text': c.text}, @@ -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, @@ -569,7 +577,7 @@ class ResourceLink extends Content { factory ResourceLink.fromJson(Map 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?, diff --git a/lib/src/types/resources.dart b/lib/src/types/resources.dart index 52dc0a77..9e9851e0 100644 --- a/lib/src/types/resources.dart +++ b/lib/src/types/resources.dart @@ -100,7 +100,7 @@ class Resource { /// Creates from JSON. factory Resource.fromJson(Map 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?, @@ -122,18 +122,20 @@ class Resource { } /// Converts to JSON. - Map 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 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. @@ -184,7 +186,10 @@ class ResourceTemplate { /// Creates from JSON. factory ResourceTemplate.fromJson(Map 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?, @@ -205,18 +210,22 @@ class ResourceTemplate { } /// Converts to JSON. - Map 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 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. @@ -461,7 +470,10 @@ class ReadResourceRequest { factory ReadResourceRequest.fromJson(Map json) => ReadResourceRequest( - uri: json['uri'] as String, + uri: readRequiredAbsoluteUriString( + json['uri'], + 'ReadResourceRequest.uri', + ), inputResponses: InputResponse.mapFromJson( json['inputResponses'], 'ReadResourceRequest.inputResponses', @@ -472,12 +484,15 @@ class ReadResourceRequest { ), ); - Map toJson() => { - 'uri': uri, - if (inputResponses != null) - 'inputResponses': InputResponse.mapToJson(inputResponses!), - if (requestState != null) 'requestState': requestState, - }; + Map 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. @@ -583,9 +598,14 @@ class SubscribeRequest { const SubscribeRequest({required this.uri}); factory SubscribeRequest.fromJson(Map json) => - SubscribeRequest(uri: json['uri'] as String); + SubscribeRequest( + uri: readRequiredAbsoluteUriString(json['uri'], 'SubscribeRequest.uri'), + ); - Map toJson() => {'uri': uri}; + Map toJson() { + validateAbsoluteUriString(uri, 'SubscribeRequest.uri'); + return {'uri': uri}; + } } /// Request sent from client to subscribe to updates for a resource. @@ -621,9 +641,17 @@ class UnsubscribeRequest { const UnsubscribeRequest({required this.uri}); factory UnsubscribeRequest.fromJson(Map json) => - UnsubscribeRequest(uri: json['uri'] as String); + UnsubscribeRequest( + uri: readRequiredAbsoluteUriString( + json['uri'], + 'UnsubscribeRequest.uri', + ), + ); - Map toJson() => {'uri': uri}; + Map toJson() { + validateAbsoluteUriString(uri, 'UnsubscribeRequest.uri'); + return {'uri': uri}; + } } /// Request sent from client to cancel a resource subscription. @@ -661,9 +689,17 @@ class ResourceUpdatedNotification { factory ResourceUpdatedNotification.fromJson( Map json, ) => - ResourceUpdatedNotification(uri: json['uri'] as String); + ResourceUpdatedNotification( + uri: readRequiredAbsoluteUriString( + json['uri'], + 'ResourceUpdatedNotification.uri', + ), + ); - Map toJson() => {'uri': uri}; + Map toJson() { + validateAbsoluteUriString(uri, 'ResourceUpdatedNotification.uri'); + return {'uri': uri}; + } } /// Notification from server indicating a subscribed resource has changed. diff --git a/lib/src/types/roots.dart b/lib/src/types/roots.dart index 6eb2c323..648b7fd2 100644 --- a/lib/src/types/roots.dart +++ b/lib/src/types/roots.dart @@ -1,6 +1,24 @@ import 'json_rpc.dart'; import 'validation.dart'; +String _readRootUri(Object? value) { + final uri = readRequiredString(value, 'Root.uri'); + if (!isAbsoluteUriString(uri)) { + throw const FormatException('Root.uri must be an absolute URI'); + } + if (!uri.startsWith('file://')) { + throw const FormatException('Root.uri must start with file://'); + } + return uri; +} + +void _validateRootUri(String uri) { + validateAbsoluteUriString(uri, 'Root.uri'); + if (!uri.startsWith('file://')) { + throw ArgumentError.value(uri, 'uri', 'Root.uri must start with file://'); + } +} + /// Represents a root directory or file the server can operate on. class Root { /// URI identifying the root (must start with `file://`). @@ -17,24 +35,25 @@ class Root { this.name, this.meta, }) { - if (!uri.startsWith('file://')) { - throw ArgumentError.value(uri, 'uri', 'Root.uri must start with file://'); - } + _validateRootUri(uri); } factory Root.fromJson(Map json) { return Root( - uri: json['uri'] as String, + uri: _readRootUri(json['uri']), name: json['name'] as String?, meta: readOptionalJsonObject(json['_meta'], 'Root._meta'), ); } - Map toJson() => { - 'uri': uri, - if (name != null) 'name': name, - if (meta != null) '_meta': readJsonObject(meta, 'Root._meta'), - }; + Map toJson() { + _validateRootUri(uri); + return { + 'uri': uri, + if (name != null) 'name': name, + if (meta != null) '_meta': readJsonObject(meta, 'Root._meta'), + }; + } } /// Request sent from server to client to get the list of root URIs. diff --git a/lib/src/types/validation.dart b/lib/src/types/validation.dart index 7e35bb81..5c140de0 100644 --- a/lib/src/types/validation.dart +++ b/lib/src/types/validation.dart @@ -1,3 +1,54 @@ +import '../shared/uri_template.dart'; + +String readRequiredString(Object? value, String field) { + if (value is String) { + return value; + } + throw FormatException('$field must be a string'); +} + +bool isAbsoluteUriString(String value) { + return Uri.tryParse(value)?.hasScheme ?? false; +} + +String readRequiredAbsoluteUriString(Object? value, String field) { + final result = readRequiredString(value, field); + if (!isAbsoluteUriString(result)) { + throw FormatException('$field must be an absolute URI'); + } + return result; +} + +void validateAbsoluteUriString(String value, String field) { + if (!isAbsoluteUriString(value)) { + throw ArgumentError.value(value, field, 'must be an absolute URI'); + } +} + +String readRequiredUriTemplateString(Object? value, String field) { + final result = readRequiredString(value, field); + try { + UriTemplateExpander(result); + } on ArgumentError catch (error) { + throw FormatException( + '$field must be a URI template: ${error.message}', + ); + } + return result; +} + +void validateUriTemplateString(String value, String field) { + try { + UriTemplateExpander(value); + } on ArgumentError catch (error) { + throw ArgumentError.value( + value, + field, + 'must be a URI template: ' + '${error.message}'); + } +} + double? readUnitDouble(Object? value, String field) { final number = readOptionalFiniteNumber(value, field); final result = number?.toDouble(); diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index eaf3f221..b1476619 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1510,6 +1510,10 @@ void main() { () => Root(uri: 'https://example.com'), throwsA(isA()), ); + expect( + () => Root.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); expect( () => ModelPreferences(costPriority: 2).toJson(), throwsA(anyOf(isA(), isA())), diff --git a/test/server/server_test.dart b/test/server/server_test.dart index 7b5e6dda..7c1585c4 100644 --- a/test/server/server_test.dart +++ b/test/server/server_test.dart @@ -507,7 +507,7 @@ void main() { // Send resource updated notification final resourceParams = const ResourceUpdatedNotification( - uri: 'test-resource', + uri: 'file:///test-resource', ); await resourceServer.sendResourceUpdated(resourceParams); @@ -542,7 +542,7 @@ void main() { ); final resourceParams = const ResourceUpdatedNotification( - uri: 'test-resource', + uri: 'file:///test-resource', ); expect( () => plainServer.sendResourceUpdated(resourceParams), diff --git a/test/shared/uri_template_test.dart b/test/shared/uri_template_test.dart index 2589f47d..c2c0bffa 100644 --- a/test/shared/uri_template_test.dart +++ b/test/shared/uri_template_test.dart @@ -295,6 +295,49 @@ void main() { ); }); + test('throws on unmatched closing expression', () { + expect( + () => UriTemplateExpander('/path/}'), + throwsA( + isA() + .having((e) => e.message, 'message', contains('Unmatched')), + ), + ); + }); + + test('throws on invalid variable specs', () { + expect( + () => UriTemplateExpander('/path/{valid,}'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid variable name'), + ), + ), + ); + expect( + () => UriTemplateExpander('/path/{invalid-name}'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid variable name'), + ), + ), + ); + expect( + () => UriTemplateExpander('/path/{var:0}'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid prefix modifier'), + ), + ), + ); + }); + test('throws on template too long', () { final longTemplate = 'a' * (maxTemplateLength + 1); expect( diff --git a/test/types/resources_test.dart b/test/types/resources_test.dart index 64a7df5d..d21d709c 100644 --- a/test/types/resources_test.dart +++ b/test/types/resources_test.dart @@ -829,4 +829,106 @@ void main() { expect(notification.meta!['key'], equals('value')); }); }); + + group('Resource URI format validation', () { + test('rejects non-absolute resource URIs from wire JSON', () { + expect( + () => Resource.fromJson({'uri': 'relative/path', 'name': 'Relative'}), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'relative/path', + 'name': 'Relative', + }), + throwsA(isA()), + ); + expect( + () => ReadResourceRequest.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); + expect( + () => SubscribeRequest.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); + expect( + () => UnsubscribeRequest.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); + expect( + () => ResourceUpdatedNotification.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); + }); + + test('rejects non-absolute resource URIs during serialization', () { + expect( + () => const Resource(uri: 'relative/path', name: 'Relative').toJson(), + throwsA(isA()), + ); + expect( + () => const TextResourceContents( + uri: 'relative/path', + text: 'Relative', + ).toJson(), + throwsA(isA()), + ); + expect( + () => const ResourceLink( + uri: 'relative/path', + name: 'Relative', + ).toJson(), + throwsA(isA()), + ); + expect( + () => const ReadResourceRequest(uri: 'relative/path').toJson(), + throwsA(isA()), + ); + expect( + () => const SubscribeRequest(uri: 'relative/path').toJson(), + throwsA(isA()), + ); + expect( + () => const UnsubscribeRequest(uri: 'relative/path').toJson(), + throwsA(isA()), + ); + expect( + () => const ResourceUpdatedNotification(uri: 'relative/path').toJson(), + throwsA(isA()), + ); + }); + + test('rejects malformed resource URI templates', () { + expect( + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///{path', + 'name': 'Bad Template', + }), + throwsA(isA()), + ); + expect( + () => const ResourceTemplate( + uriTemplate: 'file:///{path', + name: 'Bad Template', + ).toJson(), + throwsA(isA()), + ); + expect( + () => ResourceReference.fromJson({ + 'type': 'ref/resource', + 'uri': 'file:///{path', + }), + throwsA(isA()), + ); + expect( + () => const ResourceReference(uri: 'file:///{path').toJson(), + throwsA(isA()), + ); + }); + }); }