odata_duty 0.30.1 → 0.31.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 +28 -1
- data/README.md +3 -2
- data/lib/metadata.xml.erb +26 -5
- data/lib/odata_duty/complex_type.rb +9 -0
- data/lib/odata_duty/edmx_schema.rb +6 -0
- data/lib/odata_duty/enum_type.rb +14 -4
- data/lib/odata_duty/errors.rb +2 -0
- data/lib/odata_duty/mcp_identifier_validator.rb +30 -0
- data/lib/odata_duty/mcp_input_schemas.rb +45 -15
- data/lib/odata_duty/mcp_server_builder.rb +56 -25
- data/lib/odata_duty/oas2/collection_get_path.rb +10 -2
- data/lib/odata_duty/oas2/collection_post_path.rb +10 -2
- data/lib/odata_duty/oas2/individual_delete_path.rb +10 -2
- data/lib/odata_duty/oas2/individual_get_path.rb +9 -2
- data/lib/odata_duty/oas2/individual_patch_path.rb +10 -2
- data/lib/odata_duty/oas2.rb +2 -0
- data/lib/odata_duty/operation_verbs.rb +14 -0
- data/lib/odata_duty/property/single_prop.rb +5 -2
- data/lib/odata_duty/property.rb +15 -2
- data/lib/odata_duty/schema_builder/complex_type.rb +1 -1
- data/lib/odata_duty/schema_builder/data_type.rb +5 -4
- data/lib/odata_duty/schema_builder/endpoint.rb +1 -0
- data/lib/odata_duty/schema_builder/entity_set.rb +4 -2
- data/lib/odata_duty/schema_builder/enum_type.rb +5 -3
- data/lib/odata_duty/schema_builder.rb +5 -1
- data/lib/odata_duty.rb +24 -39
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c58f2ad8e1d2a92312d71682cab83159e525dcd5ff2e774b64ccb1c2b91bdef2
|
|
4
|
+
data.tar.gz: d368f2c3fdbe2ea07508df7087290831694c281b2e22e07243a00393f43bfd52
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b288c003fc87adca34c4eb48f389c00d60dc69f0b4de8ec1c88b2046c7259f2c8f0f7168ce954a6ec021767b9cdc150fdfe7559d269ee54eeb61a3659be551b
|
|
7
|
+
data.tar.gz: 925162bd086fd4a22ccbff521380c67375387c2ae0f6aea954978d21d5d215cdec9b4c0ea3bec6cbdc3c3fbed0f8c17a045f345ef9ccab293bf6441f0c6a201e
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.31.0] - 2026-08-16
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `description:` keyword/macro on every schema element — schema, entity/complex/enum type,
|
|
13
|
+
enum member, property (including `property_ref`), and entity set — in both the class-based
|
|
14
|
+
and builder DSLs. Descriptions render into `$metadata` (`Core.Description` annotations),
|
|
15
|
+
`$oas2` (`info`, definitions, properties, operation `summary`/`description`), and MCP
|
|
16
|
+
(tool descriptions, input-schema property descriptions, server `instructions`) (#63).
|
|
17
|
+
- Build-time MCP identifier validation: `to_mcp_server` now raises
|
|
18
|
+
`OdataDuty::InvalidMcpIdentifierError` before returning a server if a generated tool name
|
|
19
|
+
or input-schema property key would violate the Anthropic Messages API's identifier
|
|
20
|
+
constraints — non-ASCII or oversized property names, oversized entity-set-derived tool
|
|
21
|
+
names, or a property colliding with a reserved `odata_*` key (#62).
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- **Breaking (MCP tool shape):** the MCP tools' OData query-option arguments are renamed
|
|
25
|
+
from `$filter`, `$select`, `$search`, `$top`, and `$skip` to `odata_filter`,
|
|
26
|
+
`odata_select`, `odata_search`, `odata_top`, and `odata_skip`. `$`-prefixed keys violate
|
|
27
|
+
the Anthropic Messages API's tool-schema identifier pattern and previously broke
|
|
28
|
+
`tools/list` for any Claude-based MCP client. The OData HTTP endpoints are unaffected —
|
|
29
|
+
they still use the standard `$`-prefixed query options (#62).
|
|
30
|
+
|
|
31
|
+
### Documentation
|
|
32
|
+
- New `doc/using_descriptions.md`; `doc/using_mcp.md` and `doc/using_oas2.md` updated for
|
|
33
|
+
descriptions and the new MCP argument names.
|
|
34
|
+
|
|
9
35
|
## [0.30.1] - 2026-08-02
|
|
10
36
|
|
|
11
37
|
### Added
|
|
@@ -58,7 +84,8 @@ All notable changes to this project are documented here. The format is based on
|
|
|
58
84
|
See the [git tags](https://github.com/NEXL-LTS/odata_duty-ruby/tags) for the history of
|
|
59
85
|
releases prior to 0.21.0.
|
|
60
86
|
|
|
61
|
-
[Unreleased]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.
|
|
87
|
+
[Unreleased]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.31.0...HEAD
|
|
88
|
+
[0.31.0]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.30.1...v0.31.0
|
|
62
89
|
[0.30.1]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.30.0...v0.30.1
|
|
63
90
|
[0.30.0]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.21.4...v0.30.0
|
|
64
91
|
[0.21.4]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.21.3...v0.21.4
|
data/README.md
CHANGED
|
@@ -74,8 +74,9 @@ See the [Entity Set Generator documentation](doc/entity_set_generator.md) for mo
|
|
|
74
74
|
require 'odata_duty'
|
|
75
75
|
|
|
76
76
|
class PersonEntity < OdataDuty::EntityType
|
|
77
|
+
description 'People present at the event'
|
|
77
78
|
property_ref 'id', String
|
|
78
|
-
property 'user_name', String, nullable: false
|
|
79
|
+
property 'user_name', String, nullable: false, description: 'Unique login handle'
|
|
79
80
|
property 'name', String
|
|
80
81
|
property 'emails', [String], nullable: false
|
|
81
82
|
end
|
|
@@ -249,6 +250,7 @@ end
|
|
|
249
250
|
- [Using `$search`](doc/using_search.md)
|
|
250
251
|
- [Using `create`, `update`, and `delete`](doc/using_create_update_and_delete.md)
|
|
251
252
|
- [Using `computed` (read-only) properties](doc/using_computed.md)
|
|
253
|
+
- [Using `description`](doc/using_descriptions.md)
|
|
252
254
|
- [Using `$oas2` (OpenAPI/Swagger) with Power Automate](doc/using_oas2.md)
|
|
253
255
|
|
|
254
256
|
> **Documentation convention:** Every externally-facing feature (a new DSL option,
|
|
@@ -262,7 +264,6 @@ end
|
|
|
262
264
|
## TODO
|
|
263
265
|
|
|
264
266
|
- Add support for composite keys
|
|
265
|
-
- Add support for schema descriptions
|
|
266
267
|
- Extend protocol adapters (MCP tools, resource reading)
|
|
267
268
|
|
|
268
269
|
---
|
data/lib/metadata.xml.erb
CHANGED
|
@@ -10,20 +10,34 @@
|
|
|
10
10
|
<Schema Namespace="<%= metadata.namespace %>" xmlns="http://docs.oasis-open.org/odata/ns/edm">
|
|
11
11
|
<% if metadata.version %><Annotation Term="<%= metadata.namespace %>.Version" String="<%= metadata.version %>" /><% end %>
|
|
12
12
|
<% if metadata.title %><Annotation Term="<%= metadata.namespace %>.Title" String="<%= metadata.title %>" /><% end %>
|
|
13
|
+
<% if metadata.description %><Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(metadata.description) %>" /><% end %>
|
|
13
14
|
|
|
14
15
|
<% metadata.enum_types.each do |enum_type| %>
|
|
15
16
|
<EnumType Name="<%= enum_type.name %>">
|
|
17
|
+
<% if enum_type.description %>
|
|
18
|
+
<Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(enum_type.description) %>" />
|
|
19
|
+
<% end %>
|
|
16
20
|
<% enum_type.members.each do |member| %>
|
|
17
|
-
|
|
21
|
+
<% if member.description %>
|
|
22
|
+
<Member Name="<%= member.name %>">
|
|
23
|
+
<Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(member.description) %>" />
|
|
24
|
+
</Member>
|
|
25
|
+
<% else %>
|
|
26
|
+
<Member Name="<%= member.name %>" />
|
|
27
|
+
<% end %>
|
|
18
28
|
<% end %>
|
|
19
29
|
</EnumType>
|
|
20
30
|
<% end %>
|
|
21
31
|
<% metadata.complex_types.each do |complex_type| %>
|
|
22
32
|
<ComplexType Name="<%= complex_type.name %>">
|
|
33
|
+
<% if complex_type.description %>
|
|
34
|
+
<Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(complex_type.description) %>" />
|
|
35
|
+
<% end %>
|
|
23
36
|
<% complex_type.properties.each do |property| %>
|
|
24
|
-
<% if property.core_annotation_term %>
|
|
37
|
+
<% if property.description || property.core_annotation_term %>
|
|
25
38
|
<Property Name="<%= property.name %>" Nullable="<%= property.nullable %>" Type="<%= property.collection? ? "Collection(" : "" %><%= property.type.include?(".") ? "" : "#{metadata.namespace}." %><%= property.type %><%= property.collection? ? ")" : "" %>">
|
|
26
|
-
|
|
39
|
+
<% if property.description %><Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(property.description) %>" /><% end %>
|
|
40
|
+
<% if property.core_annotation_term %><Annotation Term="<%= property.core_annotation_term %>" Bool="true" /><% end %>
|
|
27
41
|
</Property>
|
|
28
42
|
<% else %>
|
|
29
43
|
<Property Name="<%= property.name %>" Nullable="<%= property.nullable %>" Type="<%= property.collection? ? "Collection(" : "" %><%= property.type.include?(".") ? "" : "#{metadata.namespace}." %><%= property.type %><%= property.collection? ? ")" : "" %>" />
|
|
@@ -33,15 +47,19 @@
|
|
|
33
47
|
<% end %>
|
|
34
48
|
<% metadata.entity_types.each do |entity_type| %>
|
|
35
49
|
<EntityType Name="<%= entity_type.name %>">
|
|
50
|
+
<% if entity_type.description %>
|
|
51
|
+
<Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(entity_type.description) %>" />
|
|
52
|
+
<% end %>
|
|
36
53
|
<Key>
|
|
37
54
|
<% entity_type.property_refs.each do |property| %>
|
|
38
55
|
<PropertyRef Name="<%= property.name %>" />
|
|
39
56
|
<% end %>
|
|
40
57
|
</Key>
|
|
41
58
|
<% entity_type.properties.each do |property| %>
|
|
42
|
-
<% if property.core_annotation_term %>
|
|
59
|
+
<% if property.description || property.core_annotation_term %>
|
|
43
60
|
<Property Name="<%= property.name %>" Nullable="<%= property.nullable %>" Type="<%= property.collection? ? "Collection(" : "" %><%= property.type.include?(".") ? "" : "#{metadata.namespace}." %><%= property.type %><%= property.collection? ? ")" : "" %>">
|
|
44
|
-
|
|
61
|
+
<% if property.description %><Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(property.description) %>" /><% end %>
|
|
62
|
+
<% if property.core_annotation_term %><Annotation Term="<%= property.core_annotation_term %>" Bool="true" /><% end %>
|
|
45
63
|
</Property>
|
|
46
64
|
<% else %>
|
|
47
65
|
<Property Name="<%= property.name %>" Nullable="<%= property.nullable %>" Type="<%= property.collection? ? "Collection(" : "" %><%= property.type.include?(".") ? "" : "#{metadata.namespace}." %><%= property.type %><%= property.collection? ? ")" : "" %>" />
|
|
@@ -52,6 +70,9 @@
|
|
|
52
70
|
<EntityContainer Name="Container">
|
|
53
71
|
<% metadata.entity_sets.each do |entity_set| %>
|
|
54
72
|
<EntitySet Name="<%= entity_set.name %>" EntityType="<%= metadata.namespace %>.<%= entity_set.entity_type_name %>">
|
|
73
|
+
<% if entity_set.description %>
|
|
74
|
+
<Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(entity_set.description) %>" />
|
|
75
|
+
<% end %>
|
|
55
76
|
<% if entity_set.supports_search? %>
|
|
56
77
|
<Annotation Term="Capabilities.SearchRestrictions">
|
|
57
78
|
<Record>
|
|
@@ -2,6 +2,11 @@ require_relative 'mapper_builder'
|
|
|
2
2
|
|
|
3
3
|
module OdataDuty
|
|
4
4
|
class ComplexType
|
|
5
|
+
def self.description(text = nil)
|
|
6
|
+
@description = Property.resolve_description(__metadata.name, text) unless text.nil?
|
|
7
|
+
@description
|
|
8
|
+
end
|
|
9
|
+
|
|
5
10
|
def self.properties
|
|
6
11
|
@properties ||= []
|
|
7
12
|
end
|
|
@@ -35,6 +40,10 @@ module OdataDuty
|
|
|
35
40
|
name
|
|
36
41
|
end
|
|
37
42
|
|
|
43
|
+
def description
|
|
44
|
+
complex_type.description
|
|
45
|
+
end
|
|
46
|
+
|
|
38
47
|
def metadata_type
|
|
39
48
|
:complex
|
|
40
49
|
end
|
|
@@ -15,5 +15,11 @@ module OdataDuty
|
|
|
15
15
|
b = binding
|
|
16
16
|
ERB.new(File.read("#{__dir__}/../metadata.xml.erb")).result(b)
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
ESCAPE_XML_ENTITIES = { '&' => '&', '"' => '"', '<' => '<' }.freeze
|
|
20
|
+
|
|
21
|
+
def self.escape_xml(string)
|
|
22
|
+
string.gsub(/[&"<]/, ESCAPE_XML_ENTITIES)
|
|
23
|
+
end
|
|
18
24
|
end
|
|
19
25
|
end
|
data/lib/odata_duty/enum_type.rb
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
module OdataDuty
|
|
2
2
|
class EnumMember
|
|
3
|
-
attr_reader :name
|
|
3
|
+
attr_reader :name, :description
|
|
4
4
|
|
|
5
|
-
def initialize(name)
|
|
5
|
+
def initialize(name, description: nil)
|
|
6
6
|
@name = name.to_str
|
|
7
|
+
@description = Property.resolve_description(@name, description)
|
|
7
8
|
end
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
class EnumType
|
|
12
|
+
def self.description(text = nil)
|
|
13
|
+
@description = Property.resolve_description(__metadata.name, text) unless text.nil?
|
|
14
|
+
@description
|
|
15
|
+
end
|
|
16
|
+
|
|
11
17
|
def self.members
|
|
12
18
|
@members ||= []
|
|
13
19
|
end
|
|
14
20
|
|
|
15
|
-
def self.member(name)
|
|
16
|
-
members << EnumMember.new(name)
|
|
21
|
+
def self.member(name, **)
|
|
22
|
+
members << EnumMember.new(name, **)
|
|
17
23
|
end
|
|
18
24
|
|
|
19
25
|
class Metadata
|
|
@@ -27,6 +33,10 @@ module OdataDuty
|
|
|
27
33
|
enum_type.members
|
|
28
34
|
end
|
|
29
35
|
|
|
36
|
+
def description
|
|
37
|
+
enum_type.description
|
|
38
|
+
end
|
|
39
|
+
|
|
30
40
|
def scalar?
|
|
31
41
|
true
|
|
32
42
|
end
|
data/lib/odata_duty/errors.rb
CHANGED
|
@@ -3,7 +3,9 @@ module OdataDuty
|
|
|
3
3
|
|
|
4
4
|
class PropertyAlreadyDefinedError < ArgumentError; end
|
|
5
5
|
class InvalidNCNamesError < ArgumentError; end
|
|
6
|
+
class InvalidMcpIdentifierError < ArgumentError; end
|
|
6
7
|
class InitArgsMismatchError < ArgumentError; end
|
|
8
|
+
class InvalidDescriptionError < ArgumentError; end
|
|
7
9
|
|
|
8
10
|
class RequestError < Error
|
|
9
11
|
attr_reader :code, :target
|
|
@@ -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
|
|
@@ -5,27 +5,51 @@ module OdataDuty
|
|
|
5
5
|
# Property names are used as-is (symbols) for keys/required and the root `type: object` is
|
|
6
6
|
# omitted; the MCP SDK normalizes the keys and supplies the root type default.
|
|
7
7
|
|
|
8
|
+
# `$`-prefixed OData system query options are not valid Anthropic tool-schema property keys
|
|
9
|
+
# (`^[a-zA-Z0-9_.-]{1,64}$`), so tool schemas expose these `odata_*` aliases instead.
|
|
10
|
+
# McpServerBuilder uses the same mapping (inverted) to translate `tools/call` arguments back.
|
|
11
|
+
QUERY_OPTION_ALIASES = {
|
|
12
|
+
'$filter' => 'odata_filter',
|
|
13
|
+
'$select' => 'odata_select',
|
|
14
|
+
'$search' => 'odata_search',
|
|
15
|
+
'$top' => 'odata_top',
|
|
16
|
+
'$skip' => 'odata_skip'
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
8
19
|
def count_input_schema(supports_search:)
|
|
9
|
-
properties = { '$filter' => { 'type' => 'string' } }
|
|
10
|
-
properties['$search'] = { 'type' => 'string' } if supports_search
|
|
20
|
+
properties = { alias_for('$filter') => { 'type' => 'string' } }
|
|
21
|
+
properties[alias_for('$search')] = { 'type' => 'string' } if supports_search
|
|
11
22
|
{ 'properties' => properties, 'required' => [] }
|
|
12
23
|
end
|
|
13
24
|
|
|
14
25
|
def list_input_schema(supports_search:)
|
|
15
26
|
properties = {
|
|
16
|
-
'$filter' =>
|
|
17
|
-
'$select' =>
|
|
18
|
-
'description' => 'Comma-separated properties to return' }
|
|
27
|
+
alias_for('$filter') => query_option('string', 'OData $filter expression'),
|
|
28
|
+
alias_for('$select') => query_option('string', 'Comma-separated properties to return')
|
|
19
29
|
}
|
|
20
30
|
if supports_search
|
|
21
|
-
properties['$search'] =
|
|
22
|
-
|
|
31
|
+
properties[alias_for('$search')] = query_option('string',
|
|
32
|
+
'Search expression (AND, OR, NOT)')
|
|
23
33
|
end
|
|
24
|
-
properties['$top'] =
|
|
25
|
-
properties['$skip'] =
|
|
34
|
+
properties[alias_for('$top')] = query_option('integer', 'Max records to return')
|
|
35
|
+
properties[alias_for('$skip')] = query_option('integer', 'Records to skip')
|
|
26
36
|
{ 'properties' => properties, 'required' => [] }
|
|
27
37
|
end
|
|
28
38
|
|
|
39
|
+
# Raises when an entity property is literally named like a reserved `odata_*` alias and
|
|
40
|
+
# would silently overwrite (or be overwritten by) the reserved query-option key in the same
|
|
41
|
+
# `properties` hash — see McpServerBuilder's tool-name/property-key validation.
|
|
42
|
+
def add_alias!(properties, entity_type, query_option_key, value, tool_name:)
|
|
43
|
+
key = alias_for(query_option_key)
|
|
44
|
+
if properties.keys.map(&:to_s).include?(key)
|
|
45
|
+
raise InvalidMcpIdentifierError,
|
|
46
|
+
"#{entity_type.name} property \"#{key}\" collides with the reserved #{key} " \
|
|
47
|
+
"query-option key in the #{tool_name} tool input schema"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
properties[key] = value
|
|
51
|
+
end
|
|
52
|
+
|
|
29
53
|
def create_input_schema(entity_type)
|
|
30
54
|
writable = entity_type.properties.select(&:settable_on_create?)
|
|
31
55
|
properties = writable.to_h { |p| [p.name, p.to_oas2] }
|
|
@@ -41,13 +65,11 @@ module OdataDuty
|
|
|
41
65
|
{ 'properties' => properties, 'required' => [key.name] }
|
|
42
66
|
end
|
|
43
67
|
|
|
44
|
-
def get_input_schema(entity_type)
|
|
68
|
+
def get_input_schema(entity_type, tool_name:)
|
|
45
69
|
key = entity_type.property_refs.first
|
|
46
|
-
properties = {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
'description' => 'Comma-separated properties to return' }
|
|
50
|
-
}
|
|
70
|
+
properties = { key.name => key.to_oas2 }
|
|
71
|
+
select_value = query_option('string', 'Comma-separated properties to return')
|
|
72
|
+
add_alias!(properties, entity_type, '$select', select_value, tool_name: tool_name)
|
|
51
73
|
{ 'properties' => properties, 'required' => [key.name] }
|
|
52
74
|
end
|
|
53
75
|
|
|
@@ -55,5 +77,13 @@ module OdataDuty
|
|
|
55
77
|
key = entity_type.property_refs.first
|
|
56
78
|
{ 'properties' => { key.name => key.to_oas2 }, 'required' => [key.name] }
|
|
57
79
|
end
|
|
80
|
+
|
|
81
|
+
def alias_for(query_option_key)
|
|
82
|
+
QUERY_OPTION_ALIASES.fetch(query_option_key)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def query_option(type, description)
|
|
86
|
+
{ 'type' => type, 'description' => description }
|
|
87
|
+
end
|
|
58
88
|
end
|
|
59
89
|
end
|
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
require 'mcp'
|
|
2
2
|
require 'odata_duty/mcp_input_schemas'
|
|
3
|
+
require 'odata_duty/mcp_identifier_validator'
|
|
4
|
+
require 'odata_duty/operation_verbs'
|
|
3
5
|
|
|
4
6
|
module OdataDuty
|
|
5
7
|
module McpServerBuilder
|
|
6
8
|
extend self
|
|
7
9
|
|
|
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
|
+
|
|
8
14
|
def build(schema)
|
|
9
15
|
server = MCP::Server.new(
|
|
10
16
|
name: schema.title,
|
|
11
17
|
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,
|
|
12
22
|
capabilities: { tools: {} }
|
|
13
23
|
)
|
|
14
24
|
schema.endpoints.each { |endpoint| register_endpoint_tools(server, schema, endpoint) }
|
|
@@ -29,51 +39,61 @@ module OdataDuty
|
|
|
29
39
|
end
|
|
30
40
|
|
|
31
41
|
def register_update_tool(server, schema, endpoint)
|
|
32
|
-
register_key_tool(server, schema, endpoint, :update
|
|
42
|
+
register_key_tool(server, schema, endpoint, :update)
|
|
33
43
|
end
|
|
34
44
|
|
|
35
45
|
def register_delete_tool(server, schema, endpoint)
|
|
36
|
-
register_key_tool(server, schema, endpoint, :delete
|
|
46
|
+
register_key_tool(server, schema, endpoint, :delete)
|
|
37
47
|
end
|
|
38
48
|
|
|
39
49
|
def register_list_tool(server, schema, endpoint)
|
|
40
50
|
input_schema = McpInputSchemas.list_input_schema(supports_search: endpoint.supports_search?)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
description = tool_description(OperationVerbs.list(endpoint.name), endpoint)
|
|
52
|
+
tool_args = { name: "list_#{endpoint.name}", description: description,
|
|
53
|
+
input_schema: input_schema }
|
|
54
|
+
define_tool(server, schema, :execute, url_for: ->(_args) { endpoint.url }, **tool_args)
|
|
45
55
|
end
|
|
46
56
|
|
|
47
57
|
def register_count_tool(server, schema, endpoint)
|
|
48
58
|
input_schema = McpInputSchemas.count_input_schema(supports_search: endpoint.supports_search?)
|
|
59
|
+
description = tool_description(OperationVerbs.count(endpoint.name), endpoint)
|
|
60
|
+
tool_args = { name: "count_#{endpoint.name}", description: description,
|
|
61
|
+
input_schema: input_schema }
|
|
49
62
|
define_tool(server, schema, :execute,
|
|
50
|
-
url_for: ->(_args) { "#{endpoint.url}/$count" },
|
|
51
|
-
name: "count_#{endpoint.name}",
|
|
52
|
-
description: "Count #{endpoint.name} records", input_schema: input_schema)
|
|
63
|
+
url_for: ->(_args) { "#{endpoint.url}/$count" }, **tool_args)
|
|
53
64
|
end
|
|
54
65
|
|
|
55
66
|
def register_create_tool(server, schema, endpoint)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
tool_name = "create_#{endpoint.name}"
|
|
68
|
+
input_schema = McpInputSchemas.create_input_schema(endpoint.entity_type)
|
|
69
|
+
description = tool_description(OperationVerbs.create(endpoint.name), endpoint)
|
|
70
|
+
tool_args = { name: tool_name, description: description, input_schema: input_schema }
|
|
71
|
+
McpIdentifierValidator.validate_properties!(endpoint, tool_name, input_schema)
|
|
72
|
+
define_tool(server, schema, :create, url_for: ->(_args) { endpoint.url }, **tool_args)
|
|
61
73
|
end
|
|
62
74
|
|
|
63
75
|
def register_get_tool(server, schema, endpoint)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
76
|
+
tool_name = "get_#{endpoint.name}"
|
|
77
|
+
input_schema = McpInputSchemas.get_input_schema(endpoint.entity_type, tool_name: tool_name)
|
|
78
|
+
description = tool_description(OperationVerbs.get(endpoint.name), endpoint)
|
|
79
|
+
tool_args = { name: tool_name, description: description, input_schema: input_schema }
|
|
80
|
+
McpIdentifierValidator.validate_properties!(endpoint, tool_name, input_schema)
|
|
81
|
+
define_tool(server, schema, :execute, url_for: keyed_url_for(endpoint), **tool_args)
|
|
69
82
|
end
|
|
70
83
|
|
|
71
|
-
def register_key_tool(server, schema, endpoint, action
|
|
84
|
+
def register_key_tool(server, schema, endpoint, action)
|
|
85
|
+
tool_name = "#{action}_#{endpoint.name}"
|
|
72
86
|
input_schema = McpInputSchemas.public_send("#{action}_input_schema", endpoint.entity_type)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
87
|
+
description = tool_description(OperationVerbs.public_send(action, endpoint.name), endpoint)
|
|
88
|
+
tool_args = { name: tool_name, description: description, input_schema: input_schema }
|
|
89
|
+
McpIdentifierValidator.validate_properties!(endpoint, tool_name, input_schema)
|
|
90
|
+
define_tool(server, schema, action, url_for: keyed_url_for(endpoint), **tool_args)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def tool_description(verb_text, endpoint)
|
|
94
|
+
return verb_text unless endpoint.description
|
|
95
|
+
|
|
96
|
+
"#{verb_text}. #{endpoint.description}"
|
|
77
97
|
end
|
|
78
98
|
|
|
79
99
|
# Builds the `<url>('<key>')` locator from the tool arguments. The dynamic `args[key]` lookup
|
|
@@ -87,13 +107,24 @@ module OdataDuty
|
|
|
87
107
|
# On the .mutant.yml ignore list: `server_context[:context]` has only equivalent mutants
|
|
88
108
|
# (`[]` vs `fetch`/`dig`); the key is always present, so no public-API test distinguishes them.
|
|
89
109
|
def define_tool(server, schema, action, url_for:, **tool_args)
|
|
110
|
+
McpIdentifierValidator.validate_tool_name!(tool_args[:name])
|
|
90
111
|
server.define_tool(**tool_args) do |server_context:, **args|
|
|
91
112
|
McpServerBuilder.run_tool(action, url: url_for.call(args), schema: schema,
|
|
92
113
|
context: server_context[:context],
|
|
93
|
-
query_options:
|
|
114
|
+
query_options: McpServerBuilder.query_options_for(action,
|
|
115
|
+
args))
|
|
94
116
|
end
|
|
95
117
|
end
|
|
96
118
|
|
|
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
|
+
|
|
97
128
|
# On the .mutant.yml ignore list: `e.message` has only an equivalent mutant (`e`), since an
|
|
98
129
|
# OdataDuty::Error renders identically to its message in the text content block.
|
|
99
130
|
def run_tool(action, url:, schema:, context:, query_options:)
|
|
@@ -75,11 +75,19 @@ module OdataDuty
|
|
|
75
75
|
instance.respond_to?(PARAMETER_REQUIREMENTS[param['name']])
|
|
76
76
|
end
|
|
77
77
|
{
|
|
78
|
-
'operationId' => "GetCollectionOf#{entity_set.name}"
|
|
78
|
+
'operationId' => "GetCollectionOf#{entity_set.name}"
|
|
79
|
+
}.merge(summary_and_description).merge(
|
|
79
80
|
'produces' => ['application/json'],
|
|
80
81
|
'parameters' => parameters,
|
|
81
82
|
'responses' => { '200' => oas2_success_response, 'default' => DEFAULT_ERROR_RESPONSE }
|
|
82
|
-
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def summary_and_description
|
|
87
|
+
return {} unless entity_set.description
|
|
88
|
+
|
|
89
|
+
{ 'summary' => OperationVerbs.list(entity_set.name),
|
|
90
|
+
'description' => entity_set.description }
|
|
83
91
|
end
|
|
84
92
|
|
|
85
93
|
def oas2_success_response
|
|
@@ -4,11 +4,12 @@ module OdataDuty
|
|
|
4
4
|
def self.to_oas2(entity_set)
|
|
5
5
|
path_info = new(entity_set)
|
|
6
6
|
{
|
|
7
|
-
'operationId' => path_info.operation_id
|
|
7
|
+
'operationId' => path_info.operation_id
|
|
8
|
+
}.merge(path_info.summary_and_description).merge(
|
|
8
9
|
'produces' => path_info.produces,
|
|
9
10
|
'parameters' => path_info.parameters,
|
|
10
11
|
'responses' => path_info.responses
|
|
11
|
-
|
|
12
|
+
)
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def self.request_body_definition(entity_set)
|
|
@@ -28,6 +29,13 @@ module OdataDuty
|
|
|
28
29
|
"Create#{@entity_set.name}"
|
|
29
30
|
end
|
|
30
31
|
|
|
32
|
+
def summary_and_description
|
|
33
|
+
return {} unless @entity_set.description
|
|
34
|
+
|
|
35
|
+
{ 'summary' => OperationVerbs.create(@entity_set.name),
|
|
36
|
+
'description' => @entity_set.description }
|
|
37
|
+
end
|
|
38
|
+
|
|
31
39
|
def produces
|
|
32
40
|
['application/json']
|
|
33
41
|
end
|
|
@@ -4,10 +4,11 @@ module OdataDuty
|
|
|
4
4
|
def self.to_oas2(entity_set)
|
|
5
5
|
path_info = new(entity_set)
|
|
6
6
|
{
|
|
7
|
-
'operationId' => path_info.operation_id
|
|
7
|
+
'operationId' => path_info.operation_id
|
|
8
|
+
}.merge(path_info.summary_and_description).merge(
|
|
8
9
|
'parameters' => path_info.parameters,
|
|
9
10
|
'responses' => path_info.responses
|
|
10
|
-
|
|
11
|
+
)
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def initialize(entity_set)
|
|
@@ -18,6 +19,13 @@ module OdataDuty
|
|
|
18
19
|
"Delete#{@entity_set.name}"
|
|
19
20
|
end
|
|
20
21
|
|
|
22
|
+
def summary_and_description
|
|
23
|
+
return {} unless @entity_set.description
|
|
24
|
+
|
|
25
|
+
{ 'summary' => OperationVerbs.delete(@entity_set.name),
|
|
26
|
+
'description' => @entity_set.description }
|
|
27
|
+
end
|
|
28
|
+
|
|
21
29
|
def parameters
|
|
22
30
|
[
|
|
23
31
|
{ 'name' => 'id', 'in' => 'path', 'required' => true, 'type' => id_type }
|
|
@@ -3,11 +3,18 @@ module OdataDuty
|
|
|
3
3
|
class IndividualGetPath < SimpleDelegator
|
|
4
4
|
def to_oas2
|
|
5
5
|
{
|
|
6
|
-
'operationId' => "GetIndividual#{name}ById"
|
|
6
|
+
'operationId' => "GetIndividual#{name}ById"
|
|
7
|
+
}.merge(summary_and_description).merge(
|
|
7
8
|
'produces' => ['application/json'],
|
|
8
9
|
'parameters' => oas2_parameters,
|
|
9
10
|
'responses' => oas2_responses
|
|
10
|
-
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def summary_and_description
|
|
15
|
+
return {} unless description
|
|
16
|
+
|
|
17
|
+
{ 'summary' => OperationVerbs.get(name), 'description' => description }
|
|
11
18
|
end
|
|
12
19
|
|
|
13
20
|
def oas2_parameters
|
|
@@ -4,11 +4,12 @@ module OdataDuty
|
|
|
4
4
|
def self.to_oas2(entity_set)
|
|
5
5
|
path_info = new(entity_set)
|
|
6
6
|
{
|
|
7
|
-
'operationId' => path_info.operation_id
|
|
7
|
+
'operationId' => path_info.operation_id
|
|
8
|
+
}.merge(path_info.summary_and_description).merge(
|
|
8
9
|
'produces' => path_info.produces,
|
|
9
10
|
'parameters' => path_info.parameters,
|
|
10
11
|
'responses' => path_info.responses
|
|
11
|
-
|
|
12
|
+
)
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def self.request_body_definition(entity_set)
|
|
@@ -26,6 +27,13 @@ module OdataDuty
|
|
|
26
27
|
"Update#{@entity_set.name}"
|
|
27
28
|
end
|
|
28
29
|
|
|
30
|
+
def summary_and_description
|
|
31
|
+
return {} unless @entity_set.description
|
|
32
|
+
|
|
33
|
+
{ 'summary' => OperationVerbs.update(@entity_set.name),
|
|
34
|
+
'description' => @entity_set.description }
|
|
35
|
+
end
|
|
36
|
+
|
|
29
37
|
def produces
|
|
30
38
|
['application/json']
|
|
31
39
|
end
|
data/lib/odata_duty/oas2.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require_relative 'context_wrapper'
|
|
2
2
|
require_relative 'schema_builder/endpoint'
|
|
3
|
+
require_relative 'operation_verbs'
|
|
3
4
|
|
|
4
5
|
module OdataDuty
|
|
5
6
|
class OAS2
|
|
@@ -27,6 +28,7 @@ module OdataDuty
|
|
|
27
28
|
'paths' => paths, 'definitions' => definitions }
|
|
28
29
|
info['version'] = schema.version if schema.version
|
|
29
30
|
info['title'] = schema.title if schema.title
|
|
31
|
+
info['description'] = schema.description if schema.description
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
ERROR_PROPERTIES = {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module OdataDuty
|
|
2
|
+
# Generated verb text shared between MCP tool descriptions (McpServerBuilder) and the `$oas2`
|
|
3
|
+
# operation `summary` (oas2/*_path.rb), keyed by operation, so the two contracts can't drift.
|
|
4
|
+
module OperationVerbs
|
|
5
|
+
extend self
|
|
6
|
+
|
|
7
|
+
def list(name) = "List #{name} records"
|
|
8
|
+
def count(name) = "Count #{name} records"
|
|
9
|
+
def create(name) = "Create a new #{name} record"
|
|
10
|
+
def get(name) = "Get a single #{name} record by ID"
|
|
11
|
+
def update(name) = "Update an existing #{name} record"
|
|
12
|
+
def delete(name) = "Delete an existing #{name} record"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -5,9 +5,10 @@ module OdataDuty
|
|
|
5
5
|
|
|
6
6
|
class SingleProp
|
|
7
7
|
attr_reader :name, :nullable, :calling_method, :line__defined__at, :raw_type, :type,
|
|
8
|
-
:set_type, :method_name, :mutability
|
|
8
|
+
:set_type, :method_name, :mutability, :description
|
|
9
9
|
|
|
10
|
-
def initialize(name, type, line__defined__at:, nullable:, method:, mutability
|
|
10
|
+
def initialize(name, type, line__defined__at:, nullable:, method:, mutability:,
|
|
11
|
+
description:)
|
|
11
12
|
@line__defined__at = line__defined__at
|
|
12
13
|
@name = name.to_sym
|
|
13
14
|
@calling_method = method if method.respond_to?(:call)
|
|
@@ -15,6 +16,7 @@ module OdataDuty
|
|
|
15
16
|
@method_name = (method || name).to_sym
|
|
16
17
|
@nullable = nullable ? true : false
|
|
17
18
|
@mutability = mutability
|
|
19
|
+
@description = description
|
|
18
20
|
load_type_instance_vars(type)
|
|
19
21
|
end
|
|
20
22
|
|
|
@@ -104,6 +106,7 @@ module OdataDuty
|
|
|
104
106
|
to_oas2_type.dup.tap do |oas2|
|
|
105
107
|
oas2.merge!('readOnly' => true) if computed?
|
|
106
108
|
oas2.merge!('x-nullable' => true) if nullable
|
|
109
|
+
oas2.merge!('description' => description) if description
|
|
107
110
|
end
|
|
108
111
|
end
|
|
109
112
|
|
data/lib/odata_duty/property.rb
CHANGED
|
@@ -8,7 +8,7 @@ module OdataDuty
|
|
|
8
8
|
MUTABILITIES_LIST = MUTABILITIES.map(&:inspect).join(', ').freeze
|
|
9
9
|
|
|
10
10
|
def self.new(name, type = String, line__defined__at: nil, nullable: true, method: nil,
|
|
11
|
-
computed: :unset, mutability: :unset)
|
|
11
|
+
computed: :unset, mutability: :unset, description: nil)
|
|
12
12
|
unless valid_name?(name)
|
|
13
13
|
raise InvalidNCNamesError, "\"#{name}\" is not a valid property name"
|
|
14
14
|
end
|
|
@@ -18,7 +18,20 @@ module OdataDuty
|
|
|
18
18
|
line__defined__at: line__defined__at,
|
|
19
19
|
nullable: nullable,
|
|
20
20
|
method: method,
|
|
21
|
-
mutability: resolve_mutability(name, computed, mutability)
|
|
21
|
+
mutability: resolve_mutability(name, computed, mutability),
|
|
22
|
+
description: resolve_description(name, description))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.resolve_description(owner_name, description)
|
|
26
|
+
return if description.nil?
|
|
27
|
+
|
|
28
|
+
str = description.to_str if description.respond_to?(:to_str)
|
|
29
|
+
unless str.is_a?(String) && str.match?(/\S/)
|
|
30
|
+
raise InvalidDescriptionError,
|
|
31
|
+
"#{owner_name}: description must be a non-empty string"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
str
|
|
22
35
|
end
|
|
23
36
|
|
|
24
37
|
def self.resolve_mutability(name, computed, mutability)
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
module OdataDuty
|
|
2
2
|
module SchemaBuilder
|
|
3
3
|
class DataType
|
|
4
|
-
attr_reader :name, :_defined_at_
|
|
4
|
+
attr_reader :name, :_defined_at_, :description
|
|
5
5
|
|
|
6
|
-
def initialize(name:)
|
|
6
|
+
def initialize(name:, description: nil)
|
|
7
7
|
@name = name.clone
|
|
8
8
|
@_defined_at_ = caller.find { |line| !line.include?('/lib/odata_duty/') }
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
raise InvalidNCNamesError, "\"#{@name}\" is not a valid property name" unless
|
|
11
|
+
Property.valid_name?(@name)
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
@description = Property.resolve_description(@name, description)
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def scalar?; end
|
|
@@ -4,15 +4,17 @@ require_relative 'container'
|
|
|
4
4
|
module OdataDuty
|
|
5
5
|
module SchemaBuilder
|
|
6
6
|
class EntitySet < Container
|
|
7
|
-
attr_reader :entity_type, :url, :resolver, :init_args
|
|
7
|
+
attr_reader :entity_type, :url, :resolver, :init_args, :description
|
|
8
8
|
|
|
9
|
-
def initialize(entity_type:, resolver:, name: nil, url: nil, init_args: nil
|
|
9
|
+
def initialize(entity_type:, resolver:, name: nil, url: nil, init_args: nil,
|
|
10
|
+
description: nil)
|
|
10
11
|
@resolver = resolver.clone
|
|
11
12
|
name = name&.to_s || @resolver.split('::').last.sub(/Resolver\z/, '')
|
|
12
13
|
super(name: name)
|
|
13
14
|
@url = (url&.to_s || @name).clone
|
|
14
15
|
@entity_type = entity_type
|
|
15
16
|
@init_args = init_args
|
|
17
|
+
@description = Property.resolve_description(@name, description)
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def entity_type_name = entity_type.name
|
|
@@ -10,8 +10,8 @@ module OdataDuty
|
|
|
10
10
|
@members = []
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def member(
|
|
14
|
-
@members << EnumMember.new(
|
|
13
|
+
def member(...)
|
|
14
|
+
@members << EnumMember.new(...)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def scalar?
|
|
@@ -25,7 +25,9 @@ module OdataDuty
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def to_oas2
|
|
28
|
-
{ 'type' => 'string', 'enum' => members.map(&:name) }
|
|
28
|
+
{ 'type' => 'string', 'enum' => members.map(&:name) }.tap do |hash|
|
|
29
|
+
hash['description'] = description if description
|
|
30
|
+
end
|
|
29
31
|
end
|
|
30
32
|
end
|
|
31
33
|
end
|
|
@@ -11,7 +11,7 @@ module OdataDuty
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
class Schema
|
|
14
|
-
attr_reader :namespace, :host, :scheme, :base_path, :base_url, :types
|
|
14
|
+
attr_reader :namespace, :host, :scheme, :base_path, :base_url, :types, :description
|
|
15
15
|
attr_accessor :version, :title
|
|
16
16
|
|
|
17
17
|
def initialize(namespace:, host: 'localhost', scheme: 'https', base_path: '')
|
|
@@ -24,6 +24,10 @@ module OdataDuty
|
|
|
24
24
|
@containers = {}
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
def description=(text)
|
|
28
|
+
@description = Property.resolve_description(namespace, text)
|
|
29
|
+
end
|
|
30
|
+
|
|
27
31
|
def inspect
|
|
28
32
|
"#<#{self.class} @namespace=#{@namespace} @base_path=#{@base_path} \
|
|
29
33
|
containers=#{@containers.keys} types=#{@types.keys}>"
|
data/lib/odata_duty.rb
CHANGED
|
@@ -35,6 +35,11 @@ module OdataDuty
|
|
|
35
35
|
@url
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
def self.description(text = nil)
|
|
39
|
+
@description = Property.resolve_description(__metadata.name, text) unless text.nil?
|
|
40
|
+
@description
|
|
41
|
+
end
|
|
42
|
+
|
|
38
43
|
class Metadata
|
|
39
44
|
attr_reader :entity_set
|
|
40
45
|
|
|
@@ -42,6 +47,10 @@ module OdataDuty
|
|
|
42
47
|
@entity_set = entity_set
|
|
43
48
|
end
|
|
44
49
|
|
|
50
|
+
def description
|
|
51
|
+
entity_set.description
|
|
52
|
+
end
|
|
53
|
+
|
|
45
54
|
def metadata_types
|
|
46
55
|
[entity_type].map(&:__metadata).flat_map(&:metadata_types)
|
|
47
56
|
end
|
|
@@ -118,44 +127,14 @@ module OdataDuty
|
|
|
118
127
|
raise ResourceNotFoundError, "No such entity #{id}" unless result
|
|
119
128
|
end
|
|
120
129
|
|
|
121
|
-
def supports_search?
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def supports_collection?
|
|
131
|
-
# Check if the entity set class supports read by looking for the collection method
|
|
132
|
-
entity_set.method_defined?(:collection)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def supports_individual?
|
|
136
|
-
# Check if the entity set class supports read-by-id via the individual method
|
|
137
|
-
entity_set.method_defined?(:individual)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def supports_count?
|
|
141
|
-
# Check if the entity set class supports counting via the count method
|
|
142
|
-
entity_set.method_defined?(:count)
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def supports_create?
|
|
146
|
-
# Check if the entity set class supports create by looking for the create method
|
|
147
|
-
entity_set.method_defined?(:create)
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def supports_update?
|
|
151
|
-
# Check if the entity set class supports update by looking for the update method
|
|
152
|
-
entity_set.method_defined?(:update)
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def supports_delete?
|
|
156
|
-
# Check if the entity set class supports delete by looking for the delete method
|
|
157
|
-
entity_set.method_defined?(:delete)
|
|
158
|
-
end
|
|
130
|
+
def supports_search? = entity_set.method_defined?(:od_search)
|
|
131
|
+
def supports_filter_or? = entity_set.method_defined?(:od_filter_or)
|
|
132
|
+
def supports_collection? = entity_set.method_defined?(:collection)
|
|
133
|
+
def supports_individual? = entity_set.method_defined?(:individual)
|
|
134
|
+
def supports_count? = entity_set.method_defined?(:count)
|
|
135
|
+
def supports_create? = entity_set.method_defined?(:create)
|
|
136
|
+
def supports_update? = entity_set.method_defined?(:update)
|
|
137
|
+
def supports_delete? = entity_set.method_defined?(:delete)
|
|
159
138
|
|
|
160
139
|
def non_insertable_property_names
|
|
161
140
|
@non_insertable_property_names ||=
|
|
@@ -206,6 +185,11 @@ module OdataDuty
|
|
|
206
185
|
@title
|
|
207
186
|
end
|
|
208
187
|
|
|
188
|
+
def self.description(text = nil)
|
|
189
|
+
@description = Property.resolve_description(namespace, text) unless text.nil?
|
|
190
|
+
@description
|
|
191
|
+
end
|
|
192
|
+
|
|
209
193
|
def self.entity_sets(entity_sets = nil)
|
|
210
194
|
@entity_sets = entity_sets.uniq if entity_sets
|
|
211
195
|
@entity_sets
|
|
@@ -225,6 +209,7 @@ module OdataDuty
|
|
|
225
209
|
|
|
226
210
|
def version = schema.version
|
|
227
211
|
def title = schema.title
|
|
212
|
+
def description = schema.description
|
|
228
213
|
|
|
229
214
|
def namespace
|
|
230
215
|
schema.namespace
|
|
@@ -272,7 +257,7 @@ module OdataDuty
|
|
|
272
257
|
metadata = __metadata
|
|
273
258
|
metadata.check_names
|
|
274
259
|
|
|
275
|
-
|
|
260
|
+
EdmxSchema.metadata_xml(metadata)
|
|
276
261
|
end
|
|
277
262
|
|
|
278
263
|
def self.index_hash(metadata_url)
|
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.31.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-08-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: mcp
|
|
@@ -121,6 +121,7 @@ files:
|
|
|
121
121
|
- lib/odata_duty/filter.rb
|
|
122
122
|
- lib/odata_duty/filter_predicate.rb
|
|
123
123
|
- lib/odata_duty/mapper_builder.rb
|
|
124
|
+
- lib/odata_duty/mcp_identifier_validator.rb
|
|
124
125
|
- lib/odata_duty/mcp_input_schemas.rb
|
|
125
126
|
- lib/odata_duty/mcp_server_builder.rb
|
|
126
127
|
- lib/odata_duty/oas2.rb
|
|
@@ -129,6 +130,7 @@ files:
|
|
|
129
130
|
- lib/odata_duty/oas2/individual_delete_path.rb
|
|
130
131
|
- lib/odata_duty/oas2/individual_get_path.rb
|
|
131
132
|
- lib/odata_duty/oas2/individual_patch_path.rb
|
|
133
|
+
- lib/odata_duty/operation_verbs.rb
|
|
132
134
|
- lib/odata_duty/parslet_search_expression.rb
|
|
133
135
|
- lib/odata_duty/property.rb
|
|
134
136
|
- lib/odata_duty/property/collection_prop.rb
|