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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/README.md +3 -2
  4. data/lib/metadata.xml.erb +26 -5
  5. data/lib/odata_duty/capability_hooks.rb +9 -0
  6. data/lib/odata_duty/complex_type.rb +9 -0
  7. data/lib/odata_duty/edmx_schema.rb +6 -0
  8. data/lib/odata_duty/enum_type.rb +14 -4
  9. data/lib/odata_duty/errors.rb +2 -0
  10. data/lib/odata_duty/executor.rb +17 -0
  11. data/lib/odata_duty/mcp_identifier_validator.rb +30 -0
  12. data/lib/odata_duty/mcp_input_schemas.rb +68 -24
  13. data/lib/odata_duty/mcp_instructions.rb +60 -0
  14. data/lib/odata_duty/mcp_query_options.rb +59 -0
  15. data/lib/odata_duty/mcp_server_builder.rb +46 -27
  16. data/lib/odata_duty/mcp_tool_arguments.rb +46 -0
  17. data/lib/odata_duty/oas2/collection_get_parameters.rb +102 -0
  18. data/lib/odata_duty/oas2/collection_get_path.rb +13 -62
  19. data/lib/odata_duty/oas2/collection_post_path.rb +10 -2
  20. data/lib/odata_duty/oas2/individual_delete_path.rb +10 -2
  21. data/lib/odata_duty/oas2/individual_get_path.rb +9 -2
  22. data/lib/odata_duty/oas2/individual_patch_path.rb +10 -2
  23. data/lib/odata_duty/oas2.rb +2 -0
  24. data/lib/odata_duty/operation_verbs.rb +14 -0
  25. data/lib/odata_duty/property/single_prop.rb +5 -2
  26. data/lib/odata_duty/property.rb +15 -2
  27. data/lib/odata_duty/schema_builder/complex_type.rb +1 -1
  28. data/lib/odata_duty/schema_builder/data_type.rb +5 -4
  29. data/lib/odata_duty/schema_builder/endpoint.rb +17 -0
  30. data/lib/odata_duty/schema_builder/entity_set.rb +22 -3
  31. data/lib/odata_duty/schema_builder/enum_type.rb +5 -3
  32. data/lib/odata_duty/schema_builder.rb +5 -1
  33. data/lib/odata_duty.rb +29 -39
  34. metadata +9 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24b2763892ae6cc0088ab89de9281a39c0ecf100406950f6263e8a364bdd2abc
4
- data.tar.gz: 185bee55b726314b02f5add982464b0b949189a592fcff7a1ba9f6b33939b2b7
3
+ metadata.gz: 5fc1895bad51713ec481fdb8d68fc3ba169512821b6da0411c1123a3589ec39b
4
+ data.tar.gz: 686c02c58e32d49007257aec13415afb1ad57469cca572c7caf2fcb0f6192e51
5
5
  SHA512:
6
- metadata.gz: b3490f5fb35ca308175a3c08e3bc0fdcbc822b80334b1642ff073c02281e0d54d60b78a8c9155efac8c713ca1b3b4e66fb67ac99233497bd542620369e25b446
7
- data.tar.gz: 92f65ccdb8c6efb24fcea1f39af2e16651f7b4e937e7304998f56e26cccdcf2c19a7d36d84013b314f53f2bf6689d78958b3c144e3a1233aed781a5ae3f45501
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.30.1...HEAD
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
- <Member Name="<%= member.name %>" />
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
- <Annotation Term="<%= property.core_annotation_term %>" Bool="true" />
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
- <Annotation Term="<%= property.core_annotation_term %>" Bool="true" />
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>
@@ -0,0 +1,9 @@
1
+ module OdataDuty
2
+ module CapabilityHooks
3
+ FILTER_HOOK_PREFIX = 'od_filter_'.freeze
4
+
5
+ def self.filterable?(klass)
6
+ klass.public_instance_methods.any? { |name| name.start_with?(FILTER_HOOK_PREFIX) }
7
+ end
8
+ end
9
+ end
@@ -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 = { '&' => '&amp;', '"' => '&quot;', '<' => '&lt;' }.freeze
20
+
21
+ def self.escape_xml(string)
22
+ string.gsub(/[&"<]/, ESCAPE_XML_ENTITIES)
23
+ end
18
24
  end
19
25
  end
@@ -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
@@ -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
@@ -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
- def count_input_schema(supports_search:)
9
- properties = { '$filter' => { 'type' => 'string' } }
10
- properties['$search'] = { 'type' => 'string' } if supports_search
11
- { 'properties' => properties, 'required' => [] }
12
- end
13
-
14
- def list_input_schema(supports_search:)
15
- properties = {
16
- '$filter' => { 'type' => 'string', 'description' => 'OData $filter expression' },
17
- '$select' => { 'type' => 'string',
18
- 'description' => 'Comma-separated properties to return' }
19
- }
20
- if supports_search
21
- properties['$search'] = { 'type' => 'string',
22
- 'description' => 'Search expression (AND, OR, NOT)' }
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
- properties['$top'] = { 'type' => 'integer', 'description' => 'Max records to return' }
25
- properties['$skip'] = { 'type' => 'integer', 'description' => 'Records to skip' }
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
- key.name => key.to_oas2,
48
- '$select' => { 'type' => 'string',
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