odata_duty 0.30.1 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -1
- data/README.md +3 -2
- data/lib/metadata.xml.erb +26 -5
- data/lib/odata_duty/capability_hooks.rb +9 -0
- data/lib/odata_duty/complex_type.rb +9 -0
- data/lib/odata_duty/edmx_schema.rb +6 -0
- data/lib/odata_duty/enum_type.rb +14 -4
- data/lib/odata_duty/errors.rb +2 -0
- data/lib/odata_duty/executor.rb +17 -0
- data/lib/odata_duty/mcp_identifier_validator.rb +30 -0
- data/lib/odata_duty/mcp_input_schemas.rb +68 -24
- data/lib/odata_duty/mcp_instructions.rb +60 -0
- data/lib/odata_duty/mcp_query_options.rb +59 -0
- data/lib/odata_duty/mcp_server_builder.rb +46 -27
- data/lib/odata_duty/mcp_tool_arguments.rb +46 -0
- data/lib/odata_duty/oas2/collection_get_parameters.rb +102 -0
- data/lib/odata_duty/oas2/collection_get_path.rb +13 -62
- data/lib/odata_duty/oas2/collection_post_path.rb +10 -2
- data/lib/odata_duty/oas2/individual_delete_path.rb +10 -2
- data/lib/odata_duty/oas2/individual_get_path.rb +9 -2
- data/lib/odata_duty/oas2/individual_patch_path.rb +10 -2
- data/lib/odata_duty/oas2.rb +2 -0
- data/lib/odata_duty/operation_verbs.rb +14 -0
- data/lib/odata_duty/property/single_prop.rb +5 -2
- data/lib/odata_duty/property.rb +15 -2
- data/lib/odata_duty/schema_builder/complex_type.rb +1 -1
- data/lib/odata_duty/schema_builder/data_type.rb +5 -4
- data/lib/odata_duty/schema_builder/endpoint.rb +17 -0
- data/lib/odata_duty/schema_builder/entity_set.rb +22 -3
- data/lib/odata_duty/schema_builder/enum_type.rb +5 -3
- data/lib/odata_duty/schema_builder.rb +5 -1
- data/lib/odata_duty.rb +29 -39
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5fc1895bad51713ec481fdb8d68fc3ba169512821b6da0411c1123a3589ec39b
|
|
4
|
+
data.tar.gz: 686c02c58e32d49007257aec13415afb1ad57469cca572c7caf2fcb0f6192e51
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 913eadf1694619c01556d8988b1d6a4cc77b7f9697c496dad0735a6be0a812838a26341cb87c460fb0662a73db6541720fecefa46beee0edc61737799b514427
|
|
7
|
+
data.tar.gz: 3c497e407c716e90c7d27f1d94bbf9279742bfd6ee7699d2993e60fd877079871c4c70b7a0b2e56bb13b0f18f9cc9f6ce4312638feb7d9e07318f736fe427913
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,67 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.32.0] - 2026-09-08
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `odata_skiptoken` argument on the MCP `list_<Set>` tool for entity sets that define
|
|
13
|
+
`od_next_link_skiptoken`, so an agent can follow a `@odata.nextLink` over MCP instead of
|
|
14
|
+
hitting a dead end (#76).
|
|
15
|
+
- The MCP server `instructions` now describe the supported OData dialect — the `$filter`
|
|
16
|
+
grammar, `$search`, skiptoken paging, and what is explicitly unsupported — with each line
|
|
17
|
+
present only when some entity set can serve it (#76).
|
|
18
|
+
- Generated descriptions on every `odata_*` read-tool argument, and `items.enum` of the entity
|
|
19
|
+
type's property names on `$select`/`odata_select` so an unknown property is rejected before
|
|
20
|
+
it reaches the service (#76).
|
|
21
|
+
- `doc/using_paging.md`, covering `$top`, `$skip`, `$skiptoken`, and `od_next_link_skiptoken`
|
|
22
|
+
(#75).
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- **Breaking (MCP tool shape):** read tools (`list_`/`count_`/`get_<Set>`) now advertise
|
|
26
|
+
`odata_filter`, `odata_search`, `odata_top`, `odata_skip`, and `odata_skiptoken` only when
|
|
27
|
+
the entity set defines the matching hook, rather than offering every option and failing with
|
|
28
|
+
`NoImplementationError` when it is used. Filterability is inferred from any public
|
|
29
|
+
`od_filter_*` method. `odata_select` remains ungated (#76).
|
|
30
|
+
- **Breaking (MCP tool shape):** `odata_select` is now an array of property names instead of a
|
|
31
|
+
comma-separated string (#76).
|
|
32
|
+
- **Breaking (MCP server shape):** `instructions` was previously exactly the schema
|
|
33
|
+
`description:` and omitted when that was nil; it is now always present and carries the
|
|
34
|
+
generated dialect description (#76).
|
|
35
|
+
- `$oas2` collection parameters: `$filter` is capability-gated on the same rule, and
|
|
36
|
+
`$top`/`$skip` carry `minimum: 0` (#76).
|
|
37
|
+
- `$top`/`$skip` are validated as non-negative base-10 integers before dispatching to
|
|
38
|
+
`od_top`/`od_skip`, raising `InvalidQueryOptionError` for negative, non-numeric, or otherwise
|
|
39
|
+
malformed values instead of passing them through to consumer hooks (#75).
|
|
40
|
+
|
|
41
|
+
`$metadata` and REST execution are unchanged — hiding a query-option parameter does not change
|
|
42
|
+
what the service honors or rejects.
|
|
43
|
+
|
|
44
|
+
## [0.31.0] - 2026-08-16
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
- `description:` keyword/macro on every schema element — schema, entity/complex/enum type,
|
|
48
|
+
enum member, property (including `property_ref`), and entity set — in both the class-based
|
|
49
|
+
and builder DSLs. Descriptions render into `$metadata` (`Core.Description` annotations),
|
|
50
|
+
`$oas2` (`info`, definitions, properties, operation `summary`/`description`), and MCP
|
|
51
|
+
(tool descriptions, input-schema property descriptions, server `instructions`) (#63).
|
|
52
|
+
- Build-time MCP identifier validation: `to_mcp_server` now raises
|
|
53
|
+
`OdataDuty::InvalidMcpIdentifierError` before returning a server if a generated tool name
|
|
54
|
+
or input-schema property key would violate the Anthropic Messages API's identifier
|
|
55
|
+
constraints — non-ASCII or oversized property names, oversized entity-set-derived tool
|
|
56
|
+
names, or a property colliding with a reserved `odata_*` key (#62).
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
- **Breaking (MCP tool shape):** the MCP tools' OData query-option arguments are renamed
|
|
60
|
+
from `$filter`, `$select`, `$search`, `$top`, and `$skip` to `odata_filter`,
|
|
61
|
+
`odata_select`, `odata_search`, `odata_top`, and `odata_skip`. `$`-prefixed keys violate
|
|
62
|
+
the Anthropic Messages API's tool-schema identifier pattern and previously broke
|
|
63
|
+
`tools/list` for any Claude-based MCP client. The OData HTTP endpoints are unaffected —
|
|
64
|
+
they still use the standard `$`-prefixed query options (#62).
|
|
65
|
+
|
|
66
|
+
### Documentation
|
|
67
|
+
- New `doc/using_descriptions.md`; `doc/using_mcp.md` and `doc/using_oas2.md` updated for
|
|
68
|
+
descriptions and the new MCP argument names.
|
|
69
|
+
|
|
9
70
|
## [0.30.1] - 2026-08-02
|
|
10
71
|
|
|
11
72
|
### Added
|
|
@@ -58,7 +119,9 @@ All notable changes to this project are documented here. The format is based on
|
|
|
58
119
|
See the [git tags](https://github.com/NEXL-LTS/odata_duty-ruby/tags) for the history of
|
|
59
120
|
releases prior to 0.21.0.
|
|
60
121
|
|
|
61
|
-
[Unreleased]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.
|
|
122
|
+
[Unreleased]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.32.0...HEAD
|
|
123
|
+
[0.32.0]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.31.0...v0.32.0
|
|
124
|
+
[0.31.0]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.30.1...v0.31.0
|
|
62
125
|
[0.30.1]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.30.0...v0.30.1
|
|
63
126
|
[0.30.0]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.21.4...v0.30.0
|
|
64
127
|
[0.21.4]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.21.3...v0.21.4
|
data/README.md
CHANGED
|
@@ -74,8 +74,9 @@ See the [Entity Set Generator documentation](doc/entity_set_generator.md) for mo
|
|
|
74
74
|
require 'odata_duty'
|
|
75
75
|
|
|
76
76
|
class PersonEntity < OdataDuty::EntityType
|
|
77
|
+
description 'People present at the event'
|
|
77
78
|
property_ref 'id', String
|
|
78
|
-
property 'user_name', String, nullable: false
|
|
79
|
+
property 'user_name', String, nullable: false, description: 'Unique login handle'
|
|
79
80
|
property 'name', String
|
|
80
81
|
property 'emails', [String], nullable: false
|
|
81
82
|
end
|
|
@@ -249,6 +250,7 @@ end
|
|
|
249
250
|
- [Using `$search`](doc/using_search.md)
|
|
250
251
|
- [Using `create`, `update`, and `delete`](doc/using_create_update_and_delete.md)
|
|
251
252
|
- [Using `computed` (read-only) properties](doc/using_computed.md)
|
|
253
|
+
- [Using `description`](doc/using_descriptions.md)
|
|
252
254
|
- [Using `$oas2` (OpenAPI/Swagger) with Power Automate](doc/using_oas2.md)
|
|
253
255
|
|
|
254
256
|
> **Documentation convention:** Every externally-facing feature (a new DSL option,
|
|
@@ -262,7 +264,6 @@ end
|
|
|
262
264
|
## TODO
|
|
263
265
|
|
|
264
266
|
- Add support for composite keys
|
|
265
|
-
- Add support for schema descriptions
|
|
266
267
|
- Extend protocol adapters (MCP tools, resource reading)
|
|
267
268
|
|
|
268
269
|
---
|
data/lib/metadata.xml.erb
CHANGED
|
@@ -10,20 +10,34 @@
|
|
|
10
10
|
<Schema Namespace="<%= metadata.namespace %>" xmlns="http://docs.oasis-open.org/odata/ns/edm">
|
|
11
11
|
<% if metadata.version %><Annotation Term="<%= metadata.namespace %>.Version" String="<%= metadata.version %>" /><% end %>
|
|
12
12
|
<% if metadata.title %><Annotation Term="<%= metadata.namespace %>.Title" String="<%= metadata.title %>" /><% end %>
|
|
13
|
+
<% if metadata.description %><Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(metadata.description) %>" /><% end %>
|
|
13
14
|
|
|
14
15
|
<% metadata.enum_types.each do |enum_type| %>
|
|
15
16
|
<EnumType Name="<%= enum_type.name %>">
|
|
17
|
+
<% if enum_type.description %>
|
|
18
|
+
<Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(enum_type.description) %>" />
|
|
19
|
+
<% end %>
|
|
16
20
|
<% enum_type.members.each do |member| %>
|
|
17
|
-
|
|
21
|
+
<% if member.description %>
|
|
22
|
+
<Member Name="<%= member.name %>">
|
|
23
|
+
<Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(member.description) %>" />
|
|
24
|
+
</Member>
|
|
25
|
+
<% else %>
|
|
26
|
+
<Member Name="<%= member.name %>" />
|
|
27
|
+
<% end %>
|
|
18
28
|
<% end %>
|
|
19
29
|
</EnumType>
|
|
20
30
|
<% end %>
|
|
21
31
|
<% metadata.complex_types.each do |complex_type| %>
|
|
22
32
|
<ComplexType Name="<%= complex_type.name %>">
|
|
33
|
+
<% if complex_type.description %>
|
|
34
|
+
<Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(complex_type.description) %>" />
|
|
35
|
+
<% end %>
|
|
23
36
|
<% complex_type.properties.each do |property| %>
|
|
24
|
-
<% if property.core_annotation_term %>
|
|
37
|
+
<% if property.description || property.core_annotation_term %>
|
|
25
38
|
<Property Name="<%= property.name %>" Nullable="<%= property.nullable %>" Type="<%= property.collection? ? "Collection(" : "" %><%= property.type.include?(".") ? "" : "#{metadata.namespace}." %><%= property.type %><%= property.collection? ? ")" : "" %>">
|
|
26
|
-
|
|
39
|
+
<% if property.description %><Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(property.description) %>" /><% end %>
|
|
40
|
+
<% if property.core_annotation_term %><Annotation Term="<%= property.core_annotation_term %>" Bool="true" /><% end %>
|
|
27
41
|
</Property>
|
|
28
42
|
<% else %>
|
|
29
43
|
<Property Name="<%= property.name %>" Nullable="<%= property.nullable %>" Type="<%= property.collection? ? "Collection(" : "" %><%= property.type.include?(".") ? "" : "#{metadata.namespace}." %><%= property.type %><%= property.collection? ? ")" : "" %>" />
|
|
@@ -33,15 +47,19 @@
|
|
|
33
47
|
<% end %>
|
|
34
48
|
<% metadata.entity_types.each do |entity_type| %>
|
|
35
49
|
<EntityType Name="<%= entity_type.name %>">
|
|
50
|
+
<% if entity_type.description %>
|
|
51
|
+
<Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(entity_type.description) %>" />
|
|
52
|
+
<% end %>
|
|
36
53
|
<Key>
|
|
37
54
|
<% entity_type.property_refs.each do |property| %>
|
|
38
55
|
<PropertyRef Name="<%= property.name %>" />
|
|
39
56
|
<% end %>
|
|
40
57
|
</Key>
|
|
41
58
|
<% entity_type.properties.each do |property| %>
|
|
42
|
-
<% if property.core_annotation_term %>
|
|
59
|
+
<% if property.description || property.core_annotation_term %>
|
|
43
60
|
<Property Name="<%= property.name %>" Nullable="<%= property.nullable %>" Type="<%= property.collection? ? "Collection(" : "" %><%= property.type.include?(".") ? "" : "#{metadata.namespace}." %><%= property.type %><%= property.collection? ? ")" : "" %>">
|
|
44
|
-
|
|
61
|
+
<% if property.description %><Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(property.description) %>" /><% end %>
|
|
62
|
+
<% if property.core_annotation_term %><Annotation Term="<%= property.core_annotation_term %>" Bool="true" /><% end %>
|
|
45
63
|
</Property>
|
|
46
64
|
<% else %>
|
|
47
65
|
<Property Name="<%= property.name %>" Nullable="<%= property.nullable %>" Type="<%= property.collection? ? "Collection(" : "" %><%= property.type.include?(".") ? "" : "#{metadata.namespace}." %><%= property.type %><%= property.collection? ? ")" : "" %>" />
|
|
@@ -52,6 +70,9 @@
|
|
|
52
70
|
<EntityContainer Name="Container">
|
|
53
71
|
<% metadata.entity_sets.each do |entity_set| %>
|
|
54
72
|
<EntitySet Name="<%= entity_set.name %>" EntityType="<%= metadata.namespace %>.<%= entity_set.entity_type_name %>">
|
|
73
|
+
<% if entity_set.description %>
|
|
74
|
+
<Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(entity_set.description) %>" />
|
|
75
|
+
<% end %>
|
|
55
76
|
<% if entity_set.supports_search? %>
|
|
56
77
|
<Annotation Term="Capabilities.SearchRestrictions">
|
|
57
78
|
<Record>
|
|
@@ -2,6 +2,11 @@ require_relative 'mapper_builder'
|
|
|
2
2
|
|
|
3
3
|
module OdataDuty
|
|
4
4
|
class ComplexType
|
|
5
|
+
def self.description(text = nil)
|
|
6
|
+
@description = Property.resolve_description(__metadata.name, text) unless text.nil?
|
|
7
|
+
@description
|
|
8
|
+
end
|
|
9
|
+
|
|
5
10
|
def self.properties
|
|
6
11
|
@properties ||= []
|
|
7
12
|
end
|
|
@@ -35,6 +40,10 @@ module OdataDuty
|
|
|
35
40
|
name
|
|
36
41
|
end
|
|
37
42
|
|
|
43
|
+
def description
|
|
44
|
+
complex_type.description
|
|
45
|
+
end
|
|
46
|
+
|
|
38
47
|
def metadata_type
|
|
39
48
|
:complex
|
|
40
49
|
end
|
|
@@ -15,5 +15,11 @@ module OdataDuty
|
|
|
15
15
|
b = binding
|
|
16
16
|
ERB.new(File.read("#{__dir__}/../metadata.xml.erb")).result(b)
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
ESCAPE_XML_ENTITIES = { '&' => '&', '"' => '"', '<' => '<' }.freeze
|
|
20
|
+
|
|
21
|
+
def self.escape_xml(string)
|
|
22
|
+
string.gsub(/[&"<]/, ESCAPE_XML_ENTITIES)
|
|
23
|
+
end
|
|
18
24
|
end
|
|
19
25
|
end
|
data/lib/odata_duty/enum_type.rb
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
module OdataDuty
|
|
2
2
|
class EnumMember
|
|
3
|
-
attr_reader :name
|
|
3
|
+
attr_reader :name, :description
|
|
4
4
|
|
|
5
|
-
def initialize(name)
|
|
5
|
+
def initialize(name, description: nil)
|
|
6
6
|
@name = name.to_str
|
|
7
|
+
@description = Property.resolve_description(@name, description)
|
|
7
8
|
end
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
class EnumType
|
|
12
|
+
def self.description(text = nil)
|
|
13
|
+
@description = Property.resolve_description(__metadata.name, text) unless text.nil?
|
|
14
|
+
@description
|
|
15
|
+
end
|
|
16
|
+
|
|
11
17
|
def self.members
|
|
12
18
|
@members ||= []
|
|
13
19
|
end
|
|
14
20
|
|
|
15
|
-
def self.member(name)
|
|
16
|
-
members << EnumMember.new(name)
|
|
21
|
+
def self.member(name, **)
|
|
22
|
+
members << EnumMember.new(name, **)
|
|
17
23
|
end
|
|
18
24
|
|
|
19
25
|
class Metadata
|
|
@@ -27,6 +33,10 @@ module OdataDuty
|
|
|
27
33
|
enum_type.members
|
|
28
34
|
end
|
|
29
35
|
|
|
36
|
+
def description
|
|
37
|
+
enum_type.description
|
|
38
|
+
end
|
|
39
|
+
|
|
30
40
|
def scalar?
|
|
31
41
|
true
|
|
32
42
|
end
|
data/lib/odata_duty/errors.rb
CHANGED
|
@@ -3,7 +3,9 @@ module OdataDuty
|
|
|
3
3
|
|
|
4
4
|
class PropertyAlreadyDefinedError < ArgumentError; end
|
|
5
5
|
class InvalidNCNamesError < ArgumentError; end
|
|
6
|
+
class InvalidMcpIdentifierError < ArgumentError; end
|
|
6
7
|
class InitArgsMismatchError < ArgumentError; end
|
|
8
|
+
class InvalidDescriptionError < ArgumentError; end
|
|
7
9
|
|
|
8
10
|
class RequestError < Error
|
|
9
11
|
attr_reader :code, :target
|
data/lib/odata_duty/executor.rb
CHANGED
|
@@ -207,6 +207,7 @@ module OdataDuty
|
|
|
207
207
|
end
|
|
208
208
|
|
|
209
209
|
def apply_top(set_builder, top)
|
|
210
|
+
validate_non_negative_integer('$top', top)
|
|
210
211
|
if !set_builder.respond_to?(:od_top) && top
|
|
211
212
|
raise NoImplementationError, "$top not implemented for #{set_builder.class}"
|
|
212
213
|
end
|
|
@@ -215,6 +216,7 @@ module OdataDuty
|
|
|
215
216
|
end
|
|
216
217
|
|
|
217
218
|
def apply_skip(set_builder, skip)
|
|
219
|
+
validate_non_negative_integer('$skip', skip)
|
|
218
220
|
if !set_builder.respond_to?(:od_skip) && skip
|
|
219
221
|
raise NoImplementationError, "$skip not implemented for #{set_builder.class}"
|
|
220
222
|
end
|
|
@@ -222,6 +224,21 @@ module OdataDuty
|
|
|
222
224
|
set_builder.od_skip(skip) if skip
|
|
223
225
|
end
|
|
224
226
|
|
|
227
|
+
def validate_non_negative_integer(name, value)
|
|
228
|
+
return unless value
|
|
229
|
+
return if non_negative_integer?(value)
|
|
230
|
+
|
|
231
|
+
raise InvalidQueryOptionError, "'#{name}' must be a non-negative integer, got '#{value}'"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def non_negative_integer?(value)
|
|
235
|
+
return value >= 0 if value.is_a?(Integer)
|
|
236
|
+
|
|
237
|
+
Integer(value, 10) >= 0
|
|
238
|
+
rescue ArgumentError
|
|
239
|
+
false
|
|
240
|
+
end
|
|
241
|
+
|
|
225
242
|
def apply_skiptoken(set_builder, skiptoken)
|
|
226
243
|
if !set_builder.respond_to?(:od_skiptoken) && skiptoken
|
|
227
244
|
raise NoImplementationError, "$skiptoken not implemented for #{set_builder.class}"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module OdataDuty
|
|
2
|
+
# Guards `McpServerBuilder`'s generated tool names and `input_schema` property keys against the
|
|
3
|
+
# Anthropic Messages API's tool-schema identifier constraints, before a tool is registered on
|
|
4
|
+
# the MCP server. Raises `InvalidMcpIdentifierError` on the first violation found.
|
|
5
|
+
module McpIdentifierValidator
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
# Property keys additionally allow `.`.
|
|
9
|
+
PROPERTY_KEY_REGEXP = /\A[a-zA-Z0-9_.-]{1,64}\z/
|
|
10
|
+
TOOL_NAME_REGEXP = /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
11
|
+
|
|
12
|
+
def validate_tool_name!(name)
|
|
13
|
+
return if name.match?(TOOL_NAME_REGEXP)
|
|
14
|
+
|
|
15
|
+
raise InvalidMcpIdentifierError,
|
|
16
|
+
"tool name \"#{name}\" is #{name.length} characters — MCP tool names must " \
|
|
17
|
+
"match #{TOOL_NAME_REGEXP.inspect}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate_properties!(endpoint, tool_name, input_schema)
|
|
21
|
+
input_schema.fetch('properties').each_key do |key|
|
|
22
|
+
next if key.match?(PROPERTY_KEY_REGEXP)
|
|
23
|
+
|
|
24
|
+
raise InvalidMcpIdentifierError,
|
|
25
|
+
"#{endpoint.entity_type.name} property \"#{key}\" cannot be used as an MCP tool " \
|
|
26
|
+
"input key — it must match #{PROPERTY_KEY_REGEXP.inspect} (#{tool_name})"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'odata_duty/mcp_query_options'
|
|
2
|
+
|
|
1
3
|
module OdataDuty
|
|
2
4
|
module McpInputSchemas
|
|
3
5
|
extend self
|
|
@@ -5,25 +7,65 @@ module OdataDuty
|
|
|
5
7
|
# Property names are used as-is (symbols) for keys/required and the root `type: object` is
|
|
6
8
|
# omitted; the MCP SDK normalizes the keys and supplies the root type default.
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
10
|
+
# `$`-prefixed OData system query options are not valid Anthropic tool-schema property keys
|
|
11
|
+
# (`^[a-zA-Z0-9_.-]{1,64}$`), so tool schemas expose these `odata_*` aliases instead.
|
|
12
|
+
# McpServerBuilder uses the same mapping (inverted) to translate `tools/call` arguments back.
|
|
13
|
+
QUERY_OPTION_ALIASES = {
|
|
14
|
+
'$filter' => 'odata_filter',
|
|
15
|
+
'$select' => 'odata_select',
|
|
16
|
+
'$search' => 'odata_search',
|
|
17
|
+
'$top' => 'odata_top',
|
|
18
|
+
'$skip' => 'odata_skip',
|
|
19
|
+
'$skiptoken' => 'odata_skiptoken'
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
UNGATED = nil
|
|
23
|
+
|
|
24
|
+
# `[query-option key, capability predicate]` in advertised order; `UNGATED` is always
|
|
25
|
+
# advertised. The argument shape for each key lives in McpQueryOptions.
|
|
26
|
+
LIST_QUERY_OPTIONS = [
|
|
27
|
+
['$filter', :supports_filter?],
|
|
28
|
+
['$select', UNGATED],
|
|
29
|
+
['$search', :supports_search?],
|
|
30
|
+
['$top', :supports_top?],
|
|
31
|
+
['$skip', :supports_skip?],
|
|
32
|
+
['$skiptoken', :supports_skiptoken?]
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
COUNT_QUERY_OPTIONS = [
|
|
36
|
+
['$filter', :supports_filter?],
|
|
37
|
+
['$search', :supports_search?]
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
def count_input_schema(endpoint)
|
|
41
|
+
{ 'properties' => supported_query_options(COUNT_QUERY_OPTIONS, endpoint), 'required' => [] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def list_input_schema(endpoint)
|
|
45
|
+
{ 'properties' => supported_query_options(LIST_QUERY_OPTIONS, endpoint), 'required' => [] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def supported_query_options(gates, endpoint)
|
|
49
|
+
definitions = McpQueryOptions.definitions(endpoint.entity_type)
|
|
50
|
+
gates.each_with_object({}) do |(key, predicate), properties|
|
|
51
|
+
next if predicate && !endpoint.public_send(predicate)
|
|
52
|
+
|
|
53
|
+
properties[alias_for(key)] = definitions.fetch(key)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Raises when an entity property is literally named like a reserved `odata_*` alias and
|
|
58
|
+
# would silently overwrite (or be overwritten by) the reserved query-option key in the same
|
|
59
|
+
# `properties` hash — see McpServerBuilder's tool-name/property-key validation.
|
|
60
|
+
def add_alias!(properties, entity_type, query_option_key, value, tool_name:)
|
|
61
|
+
key = alias_for(query_option_key)
|
|
62
|
+
if properties.keys.map(&:to_s).include?(key)
|
|
63
|
+
raise InvalidMcpIdentifierError,
|
|
64
|
+
"#{entity_type.name} property \"#{key}\" collides with the reserved #{key} " \
|
|
65
|
+
"query-option key in the #{tool_name} tool input schema"
|
|
23
66
|
end
|
|
24
|
-
|
|
25
|
-
properties[
|
|
26
|
-
{ 'properties' => properties, 'required' => [] }
|
|
67
|
+
|
|
68
|
+
properties[key] = value
|
|
27
69
|
end
|
|
28
70
|
|
|
29
71
|
def create_input_schema(entity_type)
|
|
@@ -41,13 +83,11 @@ module OdataDuty
|
|
|
41
83
|
{ 'properties' => properties, 'required' => [key.name] }
|
|
42
84
|
end
|
|
43
85
|
|
|
44
|
-
def get_input_schema(entity_type)
|
|
86
|
+
def get_input_schema(entity_type, tool_name:)
|
|
45
87
|
key = entity_type.property_refs.first
|
|
46
|
-
properties = {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
'description' => 'Comma-separated properties to return' }
|
|
50
|
-
}
|
|
88
|
+
properties = { key.name => key.to_oas2 }
|
|
89
|
+
select_value = McpQueryOptions.select_option(entity_type)
|
|
90
|
+
add_alias!(properties, entity_type, '$select', select_value, tool_name: tool_name)
|
|
51
91
|
{ 'properties' => properties, 'required' => [key.name] }
|
|
52
92
|
end
|
|
53
93
|
|
|
@@ -55,5 +95,9 @@ module OdataDuty
|
|
|
55
95
|
key = entity_type.property_refs.first
|
|
56
96
|
{ 'properties' => { key.name => key.to_oas2 }, 'required' => [key.name] }
|
|
57
97
|
end
|
|
98
|
+
|
|
99
|
+
def alias_for(query_option_key)
|
|
100
|
+
QUERY_OPTION_ALIASES.fetch(query_option_key)
|
|
101
|
+
end
|
|
58
102
|
end
|
|
59
103
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module OdataDuty
|
|
2
|
+
# Builds the MCP server `instructions` document: the schema's own description followed by a
|
|
3
|
+
# description of the OData dialect the generated tools actually speak. A per-option line is
|
|
4
|
+
# included only when at least one entity set in the schema supports that query option, so the
|
|
5
|
+
# document never advertises a capability no tool exposes.
|
|
6
|
+
module McpInstructions
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
INTRO = 'This service exposes a subset of OData v4. Query options are passed to tools as ' \
|
|
10
|
+
'`odata_*` arguments (e.g. `odata_filter` is OData `$filter`).'.freeze
|
|
11
|
+
|
|
12
|
+
FILTER_LINE = '$filter: predicates of the form `<property> <op> <value>`. Operators: eq, ' \
|
|
13
|
+
'ne, gt, ge, lt, le. Combine with all `and` or all `or` — mixing `and` with ' \
|
|
14
|
+
'`or` is not supported, nor is parenthesised grouping. Functions (contains, ' \
|
|
15
|
+
'startswith, tolower, …), arithmetic and `not` are not supported. String ' \
|
|
16
|
+
'literals use single quotes; Edm.Date and Edm.DateTimeOffset values are ISO ' \
|
|
17
|
+
'8601 (2024-01-31, 2024-01-31T00:00:00+00:00).'.freeze
|
|
18
|
+
|
|
19
|
+
SEARCH_LINE = '$search: terms combined with AND, OR, NOT. Parenthesised groups are not ' \
|
|
20
|
+
'supported.'.freeze
|
|
21
|
+
|
|
22
|
+
PAGING_LINE = 'Paging: pass odata_skiptoken with the $skiptoken value from a prior ' \
|
|
23
|
+
"response's @odata.nextLink.".freeze
|
|
24
|
+
|
|
25
|
+
UNSUPPORTED_LINE = '$orderby, $expand, $apply, $compute and $count=true are not ' \
|
|
26
|
+
'supported.'.freeze
|
|
27
|
+
|
|
28
|
+
CLOSING = 'Each tool advertises only the query options its entity set supports.'.freeze
|
|
29
|
+
|
|
30
|
+
# `[line, capability predicate]` in document order; each line is included only when some
|
|
31
|
+
# endpoint answers true to its predicate.
|
|
32
|
+
OPTION_LINES = [
|
|
33
|
+
[FILTER_LINE, :supports_filter?],
|
|
34
|
+
[SEARCH_LINE, :supports_search?],
|
|
35
|
+
[PAGING_LINE, :supports_skiptoken?]
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
def build(schema)
|
|
39
|
+
[schema.description, dialect(schema)].compact.join("\n\n")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def dialect(schema)
|
|
43
|
+
[INTRO, option_lines(schema).join("\n"), CLOSING].join("\n\n")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def option_lines(schema)
|
|
47
|
+
supported = OPTION_LINES.filter_map do |line, predicate|
|
|
48
|
+
line if schema.endpoints.any? { |endpoint| advertises?(endpoint, predicate) }
|
|
49
|
+
end
|
|
50
|
+
supported + [UNSUPPORTED_LINE]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# `$filter` and `$search` reach a set through its `list_`/`count_` tools and paging through
|
|
54
|
+
# `list_`, all of which need a `collection` method — so a set without one advertises no
|
|
55
|
+
# query options at all, whatever hooks it defines.
|
|
56
|
+
def advertises?(endpoint, predicate)
|
|
57
|
+
endpoint.supports_collection? && endpoint.public_send(predicate)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module OdataDuty
|
|
2
|
+
# Argument definitions for the `odata_*` query-option keys `McpInputSchemas` advertises on the
|
|
3
|
+
# read tools, keyed by their `$`-prefixed OData spelling. Split out of `McpInputSchemas` so the
|
|
4
|
+
# `$select` shape has one definition shared by the gated list/count tables and `get_`.
|
|
5
|
+
module McpQueryOptions
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
FILTER_DESCRIPTION =
|
|
9
|
+
'OData $filter expression; see the service instructions for the grammar. Filtering is ' \
|
|
10
|
+
'supported for this entity set, but not every property or operator combination is ' \
|
|
11
|
+
'necessarily implemented — an unsupported combination returns an error rather than an ' \
|
|
12
|
+
'empty result. Property names are listed under odata_select. ' \
|
|
13
|
+
"Example: user_name eq 'Alice'".freeze
|
|
14
|
+
|
|
15
|
+
SELECT_DESCRIPTION = 'Properties to return; omit for all.'.freeze
|
|
16
|
+
|
|
17
|
+
SEARCH_DESCRIPTION =
|
|
18
|
+
'Free-text $search expression; terms combined with AND, OR, NOT. Parenthesised groups ' \
|
|
19
|
+
'are not supported.'.freeze
|
|
20
|
+
|
|
21
|
+
TOP_DESCRIPTION = 'Maximum number of records to return.'.freeze
|
|
22
|
+
|
|
23
|
+
SKIP_DESCRIPTION = 'Number of records to skip before returning results.'.freeze
|
|
24
|
+
|
|
25
|
+
SKIPTOKEN_DESCRIPTION =
|
|
26
|
+
'Continuation token for the next page. Take it from the $skiptoken query parameter of a ' \
|
|
27
|
+
"prior response's @odata.nextLink.".freeze
|
|
28
|
+
|
|
29
|
+
def definitions(entity_type)
|
|
30
|
+
{
|
|
31
|
+
'$filter' => string_option(FILTER_DESCRIPTION),
|
|
32
|
+
'$select' => select_option(entity_type),
|
|
33
|
+
'$search' => string_option(SEARCH_DESCRIPTION),
|
|
34
|
+
'$top' => integer_option(TOP_DESCRIPTION),
|
|
35
|
+
'$skip' => integer_option(SKIP_DESCRIPTION),
|
|
36
|
+
'$skiptoken' => string_option(SKIPTOKEN_DESCRIPTION)
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# An array of property names rather than OData's comma-separated string, so the `enum` can
|
|
41
|
+
# advertise exactly which names are selectable and the MCP SDK rejects the rest before the
|
|
42
|
+
# tool handler runs. McpToolArguments joins the array back to `$select`'s OData spelling.
|
|
43
|
+
def select_option(entity_type)
|
|
44
|
+
names = entity_type.properties.map { |property| property.name.to_s }
|
|
45
|
+
{ 'type' => 'array', 'description' => SELECT_DESCRIPTION,
|
|
46
|
+
'items' => { 'type' => 'string', 'enum' => names } }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def string_option(description)
|
|
50
|
+
{ 'type' => 'string', 'description' => description }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# `minimum` lets the MCP SDK reject a negative paging argument against the tool schema
|
|
54
|
+
# before the handler runs, where over REST it reaches Executor's InvalidQueryOptionError.
|
|
55
|
+
def integer_option(description)
|
|
56
|
+
{ 'type' => 'integer', 'minimum' => 0, 'description' => description }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|