odata_duty 0.31.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c58f2ad8e1d2a92312d71682cab83159e525dcd5ff2e774b64ccb1c2b91bdef2
4
- data.tar.gz: d368f2c3fdbe2ea07508df7087290831694c281b2e22e07243a00393f43bfd52
3
+ metadata.gz: 5fc1895bad51713ec481fdb8d68fc3ba169512821b6da0411c1123a3589ec39b
4
+ data.tar.gz: 686c02c58e32d49007257aec13415afb1ad57469cca572c7caf2fcb0f6192e51
5
5
  SHA512:
6
- metadata.gz: 5b288c003fc87adca34c4eb48f389c00d60dc69f0b4de8ec1c88b2046c7259f2c8f0f7168ce954a6ec021767b9cdc150fdfe7559d269ee54eeb61a3659be551b
7
- data.tar.gz: 925162bd086fd4a22ccbff521380c67375387c2ae0f6aea954978d21d5d215cdec9b4c0ea3bec6cbdc3c3fbed0f8c17a045f345ef9ccab293bf6441f0c6a201e
6
+ metadata.gz: 913eadf1694619c01556d8988b1d6a4cc77b7f9697c496dad0735a6be0a812838a26341cb87c460fb0662a73db6541720fecefa46beee0edc61737799b514427
7
+ data.tar.gz: 3c497e407c716e90c7d27f1d94bbf9279742bfd6ee7699d2993e60fd877079871c4c70b7a0b2e56bb13b0f18f9cc9f6ce4312638feb7d9e07318f736fe427913
data/CHANGELOG.md CHANGED
@@ -6,6 +6,41 @@ 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
+
9
44
  ## [0.31.0] - 2026-08-16
10
45
 
11
46
  ### Added
@@ -84,7 +119,8 @@ All notable changes to this project are documented here. The format is based on
84
119
  See the [git tags](https://github.com/NEXL-LTS/odata_duty-ruby/tags) for the history of
85
120
  releases prior to 0.21.0.
86
121
 
87
- [Unreleased]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.31.0...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
88
124
  [0.31.0]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.30.1...v0.31.0
89
125
  [0.30.1]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.30.0...v0.30.1
90
126
  [0.30.0]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.21.4...v0.30.0
@@ -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
@@ -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}"
@@ -1,3 +1,5 @@
1
+ require 'odata_duty/mcp_query_options'
2
+
1
3
  module OdataDuty
2
4
  module McpInputSchemas
3
5
  extend self
@@ -13,27 +15,43 @@ module OdataDuty
13
15
  '$select' => 'odata_select',
14
16
  '$search' => 'odata_search',
15
17
  '$top' => 'odata_top',
16
- '$skip' => 'odata_skip'
18
+ '$skip' => 'odata_skip',
19
+ '$skiptoken' => 'odata_skiptoken'
17
20
  }.freeze
18
21
 
19
- def count_input_schema(supports_search:)
20
- properties = { alias_for('$filter') => { 'type' => 'string' } }
21
- properties[alias_for('$search')] = { 'type' => 'string' } if supports_search
22
- { 'properties' => properties, 'required' => [] }
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' => [] }
23
42
  end
24
43
 
25
- def list_input_schema(supports_search:)
26
- properties = {
27
- alias_for('$filter') => query_option('string', 'OData $filter expression'),
28
- alias_for('$select') => query_option('string', 'Comma-separated properties to return')
29
- }
30
- if supports_search
31
- properties[alias_for('$search')] = query_option('string',
32
- 'Search expression (AND, OR, NOT)')
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)
33
54
  end
34
- properties[alias_for('$top')] = query_option('integer', 'Max records to return')
35
- properties[alias_for('$skip')] = query_option('integer', 'Records to skip')
36
- { 'properties' => properties, 'required' => [] }
37
55
  end
38
56
 
39
57
  # Raises when an entity property is literally named like a reserved `odata_*` alias and
@@ -68,7 +86,7 @@ module OdataDuty
68
86
  def get_input_schema(entity_type, tool_name:)
69
87
  key = entity_type.property_refs.first
70
88
  properties = { key.name => key.to_oas2 }
71
- select_value = query_option('string', 'Comma-separated properties to return')
89
+ select_value = McpQueryOptions.select_option(entity_type)
72
90
  add_alias!(properties, entity_type, '$select', select_value, tool_name: tool_name)
73
91
  { 'properties' => properties, 'required' => [key.name] }
74
92
  end
@@ -81,9 +99,5 @@ module OdataDuty
81
99
  def alias_for(query_option_key)
82
100
  QUERY_OPTION_ALIASES.fetch(query_option_key)
83
101
  end
84
-
85
- def query_option(type, description)
86
- { 'type' => type, 'description' => description }
87
- end
88
102
  end
89
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
@@ -1,24 +1,19 @@
1
1
  require 'mcp'
2
2
  require 'odata_duty/mcp_input_schemas'
3
3
  require 'odata_duty/mcp_identifier_validator'
4
+ require 'odata_duty/mcp_instructions'
5
+ require 'odata_duty/mcp_tool_arguments'
4
6
  require 'odata_duty/operation_verbs'
5
7
 
6
8
  module OdataDuty
7
9
  module McpServerBuilder
8
10
  extend self
9
11
 
10
- # Inverse of McpInputSchemas::QUERY_OPTION_ALIASES: translates a tool call's `odata_*`
11
- # arguments back to their `$`-prefixed OData spelling before they reach Executor.
12
- QUERY_OPTION_SPELLINGS = McpInputSchemas::QUERY_OPTION_ALIASES.invert.freeze
13
-
14
12
  def build(schema)
15
13
  server = MCP::Server.new(
16
14
  name: schema.title,
17
15
  version: schema.version,
18
- # Relies on the `mcp` gem's `initialize` response builder `.compact`-ing away a nil
19
- # `instructions:`, so a schema without a description omits the key rather than sending
20
- # `"instructions": null` — worth re-checking on `mcp` gem upgrades.
21
- instructions: schema.description,
16
+ instructions: McpInstructions.build(schema),
22
17
  capabilities: { tools: {} }
23
18
  )
24
19
  schema.endpoints.each { |endpoint| register_endpoint_tools(server, schema, endpoint) }
@@ -47,7 +42,7 @@ module OdataDuty
47
42
  end
48
43
 
49
44
  def register_list_tool(server, schema, endpoint)
50
- input_schema = McpInputSchemas.list_input_schema(supports_search: endpoint.supports_search?)
45
+ input_schema = McpInputSchemas.list_input_schema(endpoint)
51
46
  description = tool_description(OperationVerbs.list(endpoint.name), endpoint)
52
47
  tool_args = { name: "list_#{endpoint.name}", description: description,
53
48
  input_schema: input_schema }
@@ -55,7 +50,7 @@ module OdataDuty
55
50
  end
56
51
 
57
52
  def register_count_tool(server, schema, endpoint)
58
- input_schema = McpInputSchemas.count_input_schema(supports_search: endpoint.supports_search?)
53
+ input_schema = McpInputSchemas.count_input_schema(endpoint)
59
54
  description = tool_description(OperationVerbs.count(endpoint.name), endpoint)
60
55
  tool_args = { name: "count_#{endpoint.name}", description: description,
61
56
  input_schema: input_schema }
@@ -108,23 +103,16 @@ module OdataDuty
108
103
  # (`[]` vs `fetch`/`dig`); the key is always present, so no public-API test distinguishes them.
109
104
  def define_tool(server, schema, action, url_for:, **tool_args)
110
105
  McpIdentifierValidator.validate_tool_name!(tool_args[:name])
106
+ properties = tool_args[:input_schema].fetch('properties')
107
+ spellings = McpToolArguments.spellings_for(properties.keys)
111
108
  server.define_tool(**tool_args) do |server_context:, **args|
109
+ query_options = McpToolArguments.query_options_for(action, args, spellings)
112
110
  McpServerBuilder.run_tool(action, url: url_for.call(args), schema: schema,
113
111
  context: server_context[:context],
114
- query_options: McpServerBuilder.query_options_for(action,
115
- args))
112
+ query_options: query_options)
116
113
  end
117
114
  end
118
115
 
119
- # The `odata_*` aliases only stand in for OData query options on read (`:execute`) tools —
120
- # `:create`/`:update`/`:delete` tools' arguments are property values, so a property literally
121
- # named e.g. `odata_select` must reach Executor unchanged, not get aliased to `$select`.
122
- def query_options_for(action, args)
123
- return args.transform_keys(&:to_s) unless action == :execute
124
-
125
- args.to_h { |key, value| [QUERY_OPTION_SPELLINGS.fetch(key.to_s, key.to_s), value] }
126
- end
127
-
128
116
  # On the .mutant.yml ignore list: `e.message` has only an equivalent mutant (`e`), since an
129
117
  # OdataDuty::Error renders identically to its message in the text content block.
130
118
  def run_tool(action, url:, schema:, context:, query_options:)
@@ -0,0 +1,46 @@
1
+ require 'odata_duty/mcp_input_schemas'
2
+
3
+ module OdataDuty
4
+ # Translates a `tools/call` argument hash into the query options Executor expects, undoing the
5
+ # `odata_*` aliasing and array shapes McpInputSchemas advertises on the read tools.
6
+ module McpToolArguments
7
+ extend self
8
+
9
+ # Inverse of McpInputSchemas::QUERY_OPTION_ALIASES: translates a tool call's `odata_*`
10
+ # arguments back to their `$`-prefixed OData spelling before they reach Executor.
11
+ QUERY_OPTION_SPELLINGS = McpInputSchemas::QUERY_OPTION_ALIASES.invert.freeze
12
+
13
+ SELECT_OPTION = QUERY_OPTION_SPELLINGS.fetch(McpInputSchemas.alias_for('$select')).freeze
14
+
15
+ # The `odata_*` → `$`-prefixed spellings for the aliases one tool's input schema declares as
16
+ # query options, keyed off the schema's own property keys. McpInputSchemas keys entity
17
+ # properties by symbol and query-option aliases by string, so an entity property literally
18
+ # named `odata_skiptoken` — a `get_<Set>` tool's key, say — is not one of them.
19
+ def spellings_for(input_schema_keys)
20
+ QUERY_OPTION_SPELLINGS.slice(*input_schema_keys)
21
+ end
22
+
23
+ # The `odata_*` aliases only stand in for OData query options on read (`:execute`) tools —
24
+ # `:create`/`:update`/`:delete` tools' arguments are property values, so a property literally
25
+ # named e.g. `odata_select` must reach Executor unchanged, not get aliased to `$select`.
26
+ def query_options_for(action, args, spellings)
27
+ return args.transform_keys(&:to_s) unless action == :execute
28
+
29
+ args.to_h do |key, value|
30
+ name = key.to_s
31
+ spelling = spellings.fetch(name, name)
32
+ [spelling, query_option_value(spelling, value)]
33
+ end
34
+ end
35
+
36
+ # A declared `odata_select` is advertised as an array of property names, so it needs joining
37
+ # back to `$select`'s comma-separated OData spelling. Every other value is passed through as
38
+ # given — including an `odata_select` a tool never declared (on a `count_<Set>`, say), which
39
+ # is no more a query option than any other undeclared argument.
40
+ def query_option_value(spelling, value)
41
+ return value unless spelling == SELECT_OPTION
42
+
43
+ value.join(',')
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,102 @@
1
+ module OdataDuty
2
+ class OAS2
3
+ # The query-option parameters advertised on a collection GET operation. Each one is emitted
4
+ # only when the entity set can serve it, so the document never offers a query option the
5
+ # service would reject; `$select` is ungated, since the projection happens regardless.
6
+ module CollectionGetParameters
7
+ extend self
8
+
9
+ FILTER_NAME = '$filter'.freeze
10
+ SELECT_NAME = '$select'.freeze
11
+
12
+ COLLECTION_PARAMETERS = [
13
+ {
14
+ 'name' => FILTER_NAME,
15
+ 'in' => 'query',
16
+ 'type' => 'string',
17
+ 'description' => 'Filter the results, supporting `and` and flat `or` combinations'
18
+ },
19
+ {
20
+ 'name' => '$search',
21
+ 'in' => 'query',
22
+ 'type' => 'string',
23
+ 'description' => 'Search using structured expressions with AND, OR, NOT operators'
24
+ },
25
+ {
26
+ 'name' => SELECT_NAME,
27
+ 'in' => 'query',
28
+ 'type' => 'array',
29
+ 'items' => { 'type' => 'string' },
30
+ 'collectionFormat' => 'csv',
31
+ 'description' => 'Comma-separated list of properties to return'
32
+ },
33
+ {
34
+ 'name' => '$top',
35
+ 'in' => 'query',
36
+ 'type' => 'integer',
37
+ 'minimum' => 0,
38
+ 'description' => 'Number of results to return'
39
+ },
40
+ {
41
+ 'name' => '$skip',
42
+ 'in' => 'query',
43
+ 'type' => 'integer',
44
+ 'minimum' => 0,
45
+ 'description' => 'Number of results to skip'
46
+ },
47
+ {
48
+ 'name' => '$count',
49
+ 'in' => 'query',
50
+ 'type' => 'boolean',
51
+ 'description' => 'Include count of the results'
52
+ },
53
+ {
54
+ 'name' => '$skiptoken',
55
+ 'in' => 'query',
56
+ 'type' => 'string',
57
+ 'description' => 'Token for next page of results'
58
+ }
59
+ ].freeze
60
+
61
+ # Query options gated on the resolver responding to the single hook that serves them.
62
+ # `$filter` is gated too, but on any `od_filter_*` hook rather than one name, so it goes
63
+ # through the entity set's capability predicate instead — see `supported?`.
64
+ PARAMETER_REQUIREMENTS = {
65
+ '$top' => :od_top,
66
+ '$count' => :count,
67
+ '$skip' => :od_skip,
68
+ '$skiptoken' => :od_skiptoken,
69
+ '$search' => :od_search
70
+ }.freeze
71
+
72
+ def build(entity_set, context)
73
+ instance = entity_set.resolver_class.new(context: context, init_args: entity_set.init_args)
74
+ parameters(entity_set.entity_type).select do |param|
75
+ supported?(param.fetch('name'), entity_set, instance)
76
+ end
77
+ end
78
+
79
+ def supported?(name, entity_set, instance)
80
+ return entity_set.supports_filter? if name == FILTER_NAME
81
+ return true unless PARAMETER_REQUIREMENTS.key?(name)
82
+
83
+ instance.respond_to?(PARAMETER_REQUIREMENTS.fetch(name))
84
+ end
85
+
86
+ # `$select`'s `enum` names the entity type's own properties, so that parameter cannot be
87
+ # static the way the rest of the list is.
88
+ def parameters(entity_type)
89
+ COLLECTION_PARAMETERS.map do |param|
90
+ next param unless param.fetch('name') == SELECT_NAME
91
+
92
+ items = param.fetch('items').merge('enum' => property_names(entity_type))
93
+ param.merge('items' => items)
94
+ end
95
+ end
96
+
97
+ def property_names(entity_type)
98
+ entity_type.properties.map { |property| property.name.to_s }
99
+ end
100
+ end
101
+ end
102
+ end
@@ -1,52 +1,8 @@
1
+ require 'odata_duty/oas2/collection_get_parameters'
2
+
1
3
  module OdataDuty
2
4
  class OAS2
3
5
  CollectionGetPath = Struct.new(:entity_set, :context) do
4
- COLLECTION_PARAMETERS = [
5
- {
6
- 'name' => '$filter',
7
- 'in' => 'query',
8
- 'type' => 'string',
9
- 'description' => 'Filter the results, supporting `and` and flat `or` combinations'
10
- },
11
- {
12
- 'name' => '$search',
13
- 'in' => 'query',
14
- 'type' => 'string',
15
- 'description' => 'Search using structured expressions with AND, OR, NOT operators'
16
- },
17
- {
18
- 'name' => '$select',
19
- 'in' => 'query',
20
- 'type' => 'array',
21
- 'items' => { 'type' => 'string' },
22
- 'collectionFormat' => 'csv',
23
- 'description' => 'Comma-separated list of properties to return'
24
- },
25
- {
26
- 'name' => '$top',
27
- 'in' => 'query',
28
- 'type' => 'integer',
29
- 'description' => 'Number of results to return'
30
- },
31
- {
32
- 'name' => '$skip',
33
- 'in' => 'query',
34
- 'type' => 'integer',
35
- 'description' => 'Number of results to skip'
36
- },
37
- {
38
- 'name' => '$count',
39
- 'in' => 'query',
40
- 'type' => 'boolean',
41
- 'description' => 'Include count of the results'
42
- },
43
- {
44
- 'name' => '$skiptoken',
45
- 'in' => 'query',
46
- 'type' => 'string',
47
- 'description' => 'Token for next page of results'
48
- }
49
- ].freeze
50
6
  COLLECTION_RESPONSE_DEFAULTS = {
51
7
  '@odata.nextLink' => {
52
8
  'type' => 'string',
@@ -60,25 +16,12 @@ module OdataDuty
60
16
  }
61
17
  }.freeze
62
18
 
63
- PARAMETER_REQUIREMENTS = {
64
- '$top' => :od_top,
65
- '$count' => :count,
66
- '$skip' => :od_skip,
67
- '$skiptoken' => :od_skiptoken,
68
- '$search' => :od_search
69
- }.freeze
70
-
71
19
  def to_oas2
72
- instance = entity_set.resolver_class.new(context: context, init_args: entity_set.init_args)
73
- parameters = COLLECTION_PARAMETERS.select do |param|
74
- !PARAMETER_REQUIREMENTS.key?(param['name']) ||
75
- instance.respond_to?(PARAMETER_REQUIREMENTS[param['name']])
76
- end
77
20
  {
78
21
  'operationId' => "GetCollectionOf#{entity_set.name}"
79
22
  }.merge(summary_and_description).merge(
80
23
  'produces' => ['application/json'],
81
- 'parameters' => parameters,
24
+ 'parameters' => CollectionGetParameters.build(entity_set, context),
82
25
  'responses' => { '200' => oas2_success_response, 'default' => DEFAULT_ERROR_RESPONSE }
83
26
  )
84
27
  end
@@ -56,6 +56,22 @@ module OdataDuty
56
56
  entity_set.supports_search?
57
57
  end
58
58
 
59
+ def supports_filter?
60
+ entity_set.supports_filter?
61
+ end
62
+
63
+ def supports_top?
64
+ entity_set.supports_top?
65
+ end
66
+
67
+ def supports_skip?
68
+ entity_set.supports_skip?
69
+ end
70
+
71
+ def supports_skiptoken?
72
+ entity_set.supports_skiptoken?
73
+ end
74
+
59
75
  def supports_collection?
60
76
  entity_set.supports_collection?
61
77
  end
@@ -1,4 +1,5 @@
1
1
  require 'delegate'
2
+ require_relative '../capability_hooks'
2
3
  require_relative 'container'
3
4
 
4
5
  module OdataDuty
@@ -23,13 +24,29 @@ module OdataDuty
23
24
 
24
25
  def supports_search?
25
26
  # Check if the resolver class supports search by looking for the od_search method
26
- resolver_class.method_defined?(:od_search)
27
+ resolver_class.public_method_defined?(:od_search)
27
28
  end
28
29
 
29
30
  def supports_filter_or?
30
31
  resolver_class.method_defined?(:od_filter_or)
31
32
  end
32
33
 
34
+ def supports_filter?
35
+ CapabilityHooks.filterable?(resolver_class)
36
+ end
37
+
38
+ def supports_top?
39
+ resolver_class.public_method_defined?(:od_top)
40
+ end
41
+
42
+ def supports_skip?
43
+ resolver_class.public_method_defined?(:od_skip)
44
+ end
45
+
46
+ def supports_skiptoken?
47
+ resolver_class.public_method_defined?(:od_skiptoken)
48
+ end
49
+
33
50
  def supports_collection?
34
51
  # Check if the resolver class supports read by looking for the collection method
35
52
  resolver_class.method_defined?(:collection)
data/lib/odata_duty.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require 'odata_duty/capability_hooks'
1
2
  require 'odata_duty/errors'
2
3
  require 'odata_duty/set_resolver'
3
4
  require 'odata_duty/schema_builder'
@@ -127,8 +128,12 @@ module OdataDuty
127
128
  raise ResourceNotFoundError, "No such entity #{id}" unless result
128
129
  end
129
130
 
130
- def supports_search? = entity_set.method_defined?(:od_search)
131
+ def supports_search? = entity_set.public_method_defined?(:od_search)
131
132
  def supports_filter_or? = entity_set.method_defined?(:od_filter_or)
133
+ def supports_filter? = CapabilityHooks.filterable?(entity_set)
134
+ def supports_top? = entity_set.public_method_defined?(:od_top)
135
+ def supports_skip? = entity_set.public_method_defined?(:od_skip)
136
+ def supports_skiptoken? = entity_set.public_method_defined?(:od_skiptoken)
132
137
  def supports_collection? = entity_set.method_defined?(:collection)
133
138
  def supports_individual? = entity_set.method_defined?(:individual)
134
139
  def supports_count? = entity_set.method_defined?(:count)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: odata_duty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.31.0
4
+ version: 0.32.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Grant Petersen-Speelman
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-16 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mcp
@@ -108,6 +108,7 @@ files:
108
108
  - lib/generators/odata_duty/install/templates/schema.rb.tt
109
109
  - lib/metadata.xml.erb
110
110
  - lib/odata_duty.rb
111
+ - lib/odata_duty/capability_hooks.rb
111
112
  - lib/odata_duty/complex_type.rb
112
113
  - lib/odata_duty/context_wrapper.rb
113
114
  - lib/odata_duty/create_complex_type_hash_wrapper.rb
@@ -123,8 +124,12 @@ files:
123
124
  - lib/odata_duty/mapper_builder.rb
124
125
  - lib/odata_duty/mcp_identifier_validator.rb
125
126
  - lib/odata_duty/mcp_input_schemas.rb
127
+ - lib/odata_duty/mcp_instructions.rb
128
+ - lib/odata_duty/mcp_query_options.rb
126
129
  - lib/odata_duty/mcp_server_builder.rb
130
+ - lib/odata_duty/mcp_tool_arguments.rb
127
131
  - lib/odata_duty/oas2.rb
132
+ - lib/odata_duty/oas2/collection_get_parameters.rb
128
133
  - lib/odata_duty/oas2/collection_get_path.rb
129
134
  - lib/odata_duty/oas2/collection_post_path.rb
130
135
  - lib/odata_duty/oas2/individual_delete_path.rb