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 +4 -4
- data/CHANGELOG.md +37 -1
- data/lib/odata_duty/capability_hooks.rb +9 -0
- data/lib/odata_duty/executor.rb +17 -0
- data/lib/odata_duty/mcp_input_schemas.rb +35 -21
- 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 +9 -21
- 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 +3 -60
- data/lib/odata_duty/schema_builder/endpoint.rb +16 -0
- data/lib/odata_duty/schema_builder/entity_set.rb +18 -1
- data/lib/odata_duty.rb +6 -1
- metadata +7 -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,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.
|
|
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
|
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}"
|
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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(
|
|
26
|
-
properties
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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' =>
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|