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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24b2763892ae6cc0088ab89de9281a39c0ecf100406950f6263e8a364bdd2abc
4
- data.tar.gz: 185bee55b726314b02f5add982464b0b949189a592fcff7a1ba9f6b33939b2b7
3
+ metadata.gz: c58f2ad8e1d2a92312d71682cab83159e525dcd5ff2e774b64ccb1c2b91bdef2
4
+ data.tar.gz: d368f2c3fdbe2ea07508df7087290831694c281b2e22e07243a00393f43bfd52
5
5
  SHA512:
6
- metadata.gz: b3490f5fb35ca308175a3c08e3bc0fdcbc822b80334b1642ff073c02281e0d54d60b78a8c9155efac8c713ca1b3b4e66fb67ac99233497bd542620369e25b446
7
- data.tar.gz: 92f65ccdb8c6efb24fcea1f39af2e16651f7b4e937e7304998f56e26cccdcf2c19a7d36d84013b314f53f2bf6689d78958b3c144e3a1233aed781a5ae3f45501
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.30.1...HEAD
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
- <Member Name="<%= member.name %>" />
21
+ <% if member.description %>
22
+ <Member Name="<%= member.name %>">
23
+ <Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(member.description) %>" />
24
+ </Member>
25
+ <% else %>
26
+ <Member Name="<%= member.name %>" />
27
+ <% end %>
18
28
  <% end %>
19
29
  </EnumType>
20
30
  <% end %>
21
31
  <% metadata.complex_types.each do |complex_type| %>
22
32
  <ComplexType Name="<%= complex_type.name %>">
33
+ <% if complex_type.description %>
34
+ <Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(complex_type.description) %>" />
35
+ <% end %>
23
36
  <% complex_type.properties.each do |property| %>
24
- <% if property.core_annotation_term %>
37
+ <% if property.description || property.core_annotation_term %>
25
38
  <Property Name="<%= property.name %>" Nullable="<%= property.nullable %>" Type="<%= property.collection? ? "Collection(" : "" %><%= property.type.include?(".") ? "" : "#{metadata.namespace}." %><%= property.type %><%= property.collection? ? ")" : "" %>">
26
- <Annotation Term="<%= property.core_annotation_term %>" Bool="true" />
39
+ <% if property.description %><Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(property.description) %>" /><% end %>
40
+ <% if property.core_annotation_term %><Annotation Term="<%= property.core_annotation_term %>" Bool="true" /><% end %>
27
41
  </Property>
28
42
  <% else %>
29
43
  <Property Name="<%= property.name %>" Nullable="<%= property.nullable %>" Type="<%= property.collection? ? "Collection(" : "" %><%= property.type.include?(".") ? "" : "#{metadata.namespace}." %><%= property.type %><%= property.collection? ? ")" : "" %>" />
@@ -33,15 +47,19 @@
33
47
  <% end %>
34
48
  <% metadata.entity_types.each do |entity_type| %>
35
49
  <EntityType Name="<%= entity_type.name %>">
50
+ <% if entity_type.description %>
51
+ <Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(entity_type.description) %>" />
52
+ <% end %>
36
53
  <Key>
37
54
  <% entity_type.property_refs.each do |property| %>
38
55
  <PropertyRef Name="<%= property.name %>" />
39
56
  <% end %>
40
57
  </Key>
41
58
  <% entity_type.properties.each do |property| %>
42
- <% if property.core_annotation_term %>
59
+ <% if property.description || property.core_annotation_term %>
43
60
  <Property Name="<%= property.name %>" Nullable="<%= property.nullable %>" Type="<%= property.collection? ? "Collection(" : "" %><%= property.type.include?(".") ? "" : "#{metadata.namespace}." %><%= property.type %><%= property.collection? ? ")" : "" %>">
44
- <Annotation Term="<%= property.core_annotation_term %>" Bool="true" />
61
+ <% if property.description %><Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(property.description) %>" /><% end %>
62
+ <% if property.core_annotation_term %><Annotation Term="<%= property.core_annotation_term %>" Bool="true" /><% end %>
45
63
  </Property>
46
64
  <% else %>
47
65
  <Property Name="<%= property.name %>" Nullable="<%= property.nullable %>" Type="<%= property.collection? ? "Collection(" : "" %><%= property.type.include?(".") ? "" : "#{metadata.namespace}." %><%= property.type %><%= property.collection? ? ")" : "" %>" />
@@ -52,6 +70,9 @@
52
70
  <EntityContainer Name="Container">
53
71
  <% metadata.entity_sets.each do |entity_set| %>
54
72
  <EntitySet Name="<%= entity_set.name %>" EntityType="<%= metadata.namespace %>.<%= entity_set.entity_type_name %>">
73
+ <% if entity_set.description %>
74
+ <Annotation Term="Org.OData.Core.V1.Description" String="<%= escape_xml(entity_set.description) %>" />
75
+ <% end %>
55
76
  <% if entity_set.supports_search? %>
56
77
  <Annotation Term="Capabilities.SearchRestrictions">
57
78
  <Record>
@@ -2,6 +2,11 @@ require_relative 'mapper_builder'
2
2
 
3
3
  module OdataDuty
4
4
  class ComplexType
5
+ def self.description(text = nil)
6
+ @description = Property.resolve_description(__metadata.name, text) unless text.nil?
7
+ @description
8
+ end
9
+
5
10
  def self.properties
6
11
  @properties ||= []
7
12
  end
@@ -35,6 +40,10 @@ module OdataDuty
35
40
  name
36
41
  end
37
42
 
43
+ def description
44
+ complex_type.description
45
+ end
46
+
38
47
  def metadata_type
39
48
  :complex
40
49
  end
@@ -15,5 +15,11 @@ module OdataDuty
15
15
  b = binding
16
16
  ERB.new(File.read("#{__dir__}/../metadata.xml.erb")).result(b)
17
17
  end
18
+
19
+ ESCAPE_XML_ENTITIES = { '&' => '&amp;', '"' => '&quot;', '<' => '&lt;' }.freeze
20
+
21
+ def self.escape_xml(string)
22
+ string.gsub(/[&"<]/, ESCAPE_XML_ENTITIES)
23
+ end
18
24
  end
19
25
  end
@@ -1,19 +1,25 @@
1
1
  module OdataDuty
2
2
  class EnumMember
3
- attr_reader :name
3
+ attr_reader :name, :description
4
4
 
5
- def initialize(name)
5
+ def initialize(name, description: nil)
6
6
  @name = name.to_str
7
+ @description = Property.resolve_description(@name, description)
7
8
  end
8
9
  end
9
10
 
10
11
  class EnumType
12
+ def self.description(text = nil)
13
+ @description = Property.resolve_description(__metadata.name, text) unless text.nil?
14
+ @description
15
+ end
16
+
11
17
  def self.members
12
18
  @members ||= []
13
19
  end
14
20
 
15
- def self.member(name)
16
- members << EnumMember.new(name)
21
+ def self.member(name, **)
22
+ members << EnumMember.new(name, **)
17
23
  end
18
24
 
19
25
  class Metadata
@@ -27,6 +33,10 @@ module OdataDuty
27
33
  enum_type.members
28
34
  end
29
35
 
36
+ def description
37
+ enum_type.description
38
+ end
39
+
30
40
  def scalar?
31
41
  true
32
42
  end
@@ -3,7 +3,9 @@ module OdataDuty
3
3
 
4
4
  class PropertyAlreadyDefinedError < ArgumentError; end
5
5
  class InvalidNCNamesError < ArgumentError; end
6
+ class InvalidMcpIdentifierError < ArgumentError; end
6
7
  class InitArgsMismatchError < ArgumentError; end
8
+ class InvalidDescriptionError < ArgumentError; end
7
9
 
8
10
  class RequestError < Error
9
11
  attr_reader :code, :target
@@ -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' => { 'type' => 'string', 'description' => 'OData $filter expression' },
17
- '$select' => { 'type' => 'string',
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'] = { 'type' => 'string',
22
- 'description' => 'Search expression (AND, OR, NOT)' }
31
+ properties[alias_for('$search')] = query_option('string',
32
+ 'Search expression (AND, OR, NOT)')
23
33
  end
24
- properties['$top'] = { 'type' => 'integer', 'description' => 'Max records to return' }
25
- properties['$skip'] = { 'type' => 'integer', 'description' => 'Records to 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
- key.name => key.to_oas2,
48
- '$select' => { 'type' => 'string',
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, 'Update an existing')
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, 'Delete an existing')
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
- define_tool(server, schema, :execute,
42
- url_for: ->(_args) { endpoint.url },
43
- name: "list_#{endpoint.name}",
44
- description: "List #{endpoint.name} records", input_schema: input_schema)
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
- define_tool(server, schema, :create,
57
- url_for: ->(_args) { endpoint.url },
58
- name: "create_#{endpoint.name}",
59
- description: "Create a new #{endpoint.name} record",
60
- input_schema: McpInputSchemas.create_input_schema(endpoint.entity_type))
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
- define_tool(server, schema, :execute,
65
- url_for: keyed_url_for(endpoint),
66
- name: "get_#{endpoint.name}",
67
- description: "Get a single #{endpoint.name} record by ID",
68
- input_schema: McpInputSchemas.get_input_schema(endpoint.entity_type))
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, verb)
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
- define_tool(server, schema, action,
74
- url_for: keyed_url_for(endpoint),
75
- name: "#{action}_#{endpoint.name}",
76
- description: "#{verb} #{endpoint.name} record", input_schema: input_schema)
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: args.transform_keys(&:to_s))
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
@@ -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
 
@@ -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)
@@ -27,7 +27,7 @@ module OdataDuty
27
27
  'properties' => properties.to_h do |property|
28
28
  [property.name.to_s, property.to_oas2]
29
29
  end
30
- }
30
+ }.tap { |oas2| oas2.merge!('description' => description) if description }
31
31
  end
32
32
  end
33
33
  end
@@ -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
- return if Property.valid_name?(@name)
10
+ raise InvalidNCNamesError, "\"#{@name}\" is not a valid property name" unless
11
+ Property.valid_name?(@name)
11
12
 
12
- raise InvalidNCNamesError, "\"#{@name}\" is not a valid property name"
13
+ @description = Property.resolve_description(@name, description)
13
14
  end
14
15
 
15
16
  def scalar?; end
@@ -9,6 +9,7 @@ module OdataDuty
9
9
 
10
10
  def name = entity_set.name
11
11
  def url = entity_set.url
12
+ def description = entity_set.description
12
13
 
13
14
  def new_entity_set(context:)
14
15
  entity_set.resolver_class.new(context: context, init_args: entity_set.init_args)
@@ -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
- # Check if the entity set class supports search by looking for the od_search method
123
- entity_set.method_defined?(:od_search)
124
- end
125
-
126
- def supports_filter_or?
127
- entity_set.method_defined?(:od_filter_or)
128
- end
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
- ERB.new(File.read("#{__dir__}/metadata.xml.erb")).result(binding)
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.30.1
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-02 00:00:00.000000000 Z
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