odata_duty 0.30.0 → 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 +93 -0
- data/LICENSE +21 -0
- data/README.md +4 -3
- 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 +12 -4
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
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
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
|
+
|
|
35
|
+
## [0.30.1] - 2026-08-02
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
- `CHANGELOG.md` and a `changelog_uri` gem metadata entry.
|
|
39
|
+
- `spec.homepage` and `spec.license = 'MIT'` in the gemspec.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
- Gemspec metadata: `homepage_uri` and `source_code_uri` were previously discarded
|
|
43
|
+
because `spec.metadata` was reassigned after being set; all entries are now merged
|
|
44
|
+
into a single hash.
|
|
45
|
+
- `spec.files` now references the packaged files that actually exist (`LICENSE`,
|
|
46
|
+
`CHANGELOG.md`) instead of a non-existent `LICENSE.txt`.
|
|
47
|
+
|
|
48
|
+
## [0.30.0] - 2026-08-02
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
- Expose MCP read operations as tools in a tools-only server: `list_/get_/count_<Set>`
|
|
52
|
+
inferred from the read hooks (#60).
|
|
53
|
+
- CORS header on the `$oas2` endpoint for Power Automate compatibility (#58).
|
|
54
|
+
|
|
55
|
+
## [0.21.4] - 2026-07-26
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
- Mutation-tested and hardened the Schema entry-point API, `Executor`, `$oas2` renderer,
|
|
59
|
+
`$search` parser, entity keys, enum types, and the builder DSL; shrank the `.mutant.yml`
|
|
60
|
+
ratchet accordingly (#50–#56, #59).
|
|
61
|
+
|
|
62
|
+
### Documentation
|
|
63
|
+
- Documented nested mutability and typed-input accessor errors (#47).
|
|
64
|
+
|
|
65
|
+
## [0.21.3] - 2026-07-05
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
- Mutation testing with `mutant`; fixed recursive-type crashes it surfaced (#46).
|
|
69
|
+
|
|
70
|
+
### Documentation
|
|
71
|
+
- Documented the SimpleCov coverage workflow (#45).
|
|
72
|
+
|
|
73
|
+
## [0.21.0] - 2026-06-30
|
|
74
|
+
|
|
75
|
+
### Added
|
|
76
|
+
- `:non_insertable` property mutability value (#43).
|
|
77
|
+
|
|
78
|
+
### Documentation
|
|
79
|
+
- Documented per-operation `$oas2` request bodies (#44).
|
|
80
|
+
- Documented the `mutability:` axis and `:immutable` (#42).
|
|
81
|
+
|
|
82
|
+
## Earlier releases
|
|
83
|
+
|
|
84
|
+
See the [git tags](https://github.com/NEXL-LTS/odata_duty-ruby/tags) for the history of
|
|
85
|
+
releases prior to 0.21.0.
|
|
86
|
+
|
|
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
|
|
89
|
+
[0.30.1]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.30.0...v0.30.1
|
|
90
|
+
[0.30.0]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.21.4...v0.30.0
|
|
91
|
+
[0.21.4]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.21.3...v0.21.4
|
|
92
|
+
[0.21.3]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.21.0...v0.21.3
|
|
93
|
+
[0.21.0]: https://github.com/NEXL-LTS/odata_duty-ruby/compare/v0.20.1...v0.21.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Nexl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
CHANGED
|
@@ -11,7 +11,7 @@ It’s designed around the principle of _"define once, serve everywhere"_: you m
|
|
|
11
11
|
- ✅ Define your data model and logic in plain Ruby
|
|
12
12
|
- ✅ Support schema-based APIs (OpenAPI/Swagger)
|
|
13
13
|
- ✅ Avoid repeating business logic in multiple layers or formats
|
|
14
|
-
- ✅ Build for humans and
|
|
14
|
+
- ✅ Build for humans and work with reporting tools, automation tools, and LLMs simultaneously
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
@@ -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
|
|
@@ -92,6 +92,8 @@ executables: []
|
|
|
92
92
|
extensions: []
|
|
93
93
|
extra_rdoc_files: []
|
|
94
94
|
files:
|
|
95
|
+
- CHANGELOG.md
|
|
96
|
+
- LICENSE
|
|
95
97
|
- README.md
|
|
96
98
|
- lib/generators/odata_duty/entity_set/entity_set_generator.rb
|
|
97
99
|
- lib/generators/odata_duty/entity_set/templates/entity_set.rb.erb
|
|
@@ -119,6 +121,7 @@ files:
|
|
|
119
121
|
- lib/odata_duty/filter.rb
|
|
120
122
|
- lib/odata_duty/filter_predicate.rb
|
|
121
123
|
- lib/odata_duty/mapper_builder.rb
|
|
124
|
+
- lib/odata_duty/mcp_identifier_validator.rb
|
|
122
125
|
- lib/odata_duty/mcp_input_schemas.rb
|
|
123
126
|
- lib/odata_duty/mcp_server_builder.rb
|
|
124
127
|
- lib/odata_duty/oas2.rb
|
|
@@ -127,6 +130,7 @@ files:
|
|
|
127
130
|
- lib/odata_duty/oas2/individual_delete_path.rb
|
|
128
131
|
- lib/odata_duty/oas2/individual_get_path.rb
|
|
129
132
|
- lib/odata_duty/oas2/individual_patch_path.rb
|
|
133
|
+
- lib/odata_duty/operation_verbs.rb
|
|
130
134
|
- lib/odata_duty/parslet_search_expression.rb
|
|
131
135
|
- lib/odata_duty/property.rb
|
|
132
136
|
- lib/odata_duty/property/collection_prop.rb
|
|
@@ -141,9 +145,13 @@ files:
|
|
|
141
145
|
- lib/odata_duty/schema_builder/entity_type.rb
|
|
142
146
|
- lib/odata_duty/schema_builder/enum_type.rb
|
|
143
147
|
- lib/odata_duty/set_resolver.rb
|
|
144
|
-
homepage:
|
|
145
|
-
licenses:
|
|
148
|
+
homepage: https://github.com/NEXL-LTS/odata_duty-ruby
|
|
149
|
+
licenses:
|
|
150
|
+
- MIT
|
|
146
151
|
metadata:
|
|
152
|
+
homepage_uri: https://github.com/NEXL-LTS/odata_duty-ruby
|
|
153
|
+
source_code_uri: https://github.com/NEXL-LTS/odata_duty-ruby
|
|
154
|
+
changelog_uri: https://github.com/NEXL-LTS/odata_duty-ruby/blob/main/CHANGELOG.md
|
|
147
155
|
rubygems_mfa_required: 'true'
|
|
148
156
|
post_install_message:
|
|
149
157
|
rdoc_options: []
|