Module: OdataDuty::McpServerBuilder

Extended by:
McpServerBuilder
Included in:
McpServerBuilder
Defined in:
lib/odata_duty/mcp_server_builder.rb

Constant Summary collapse

QUERY_OPTION_SPELLINGS =

Inverse of McpInputSchemas::QUERY_OPTION_ALIASES: translates a tool call's odata_* arguments back to their $-prefixed OData spelling before they reach Executor.

McpInputSchemas::QUERY_OPTION_ALIASES.invert.freeze

Instance Method Summary collapse

Instance Method Details

#build(schema) ⇒ Object



14
15
16
17
18
19
20
21
22
23
24
25
26
# File 'lib/odata_duty/mcp_server_builder.rb', line 14

def build(schema)
  server = MCP::Server.new(
    name: schema.title,
    version: schema.version,
    # Relies on the `mcp` gem's `initialize` response builder `.compact`-ing away a nil
    # `instructions:`, so a schema without a description omits the key rather than sending
    # `"instructions": null` — worth re-checking on `mcp` gem upgrades.
    instructions: schema.description,
    capabilities: { tools: {} }
  )
  schema.endpoints.each { |endpoint| register_endpoint_tools(server, schema, endpoint) }
  server
end

#define_tool(server, schema, action, url_for:, **tool_args) ⇒ Object

On the .mutant.yml ignore list: server_context[:context] has only equivalent mutants ([] vs fetch/dig); the key is always present, so no public-API test distinguishes them.



109
110
111
112
113
114
115
116
117
# File 'lib/odata_duty/mcp_server_builder.rb', line 109

def define_tool(server, schema, action, url_for:, **tool_args)
  McpIdentifierValidator.validate_tool_name!(tool_args[:name])
  server.define_tool(**tool_args) do |server_context:, **args|
    McpServerBuilder.run_tool(action, url: url_for.call(args), schema: schema,
                                      context: server_context[:context],
                                      query_options: McpServerBuilder.query_options_for(action,
                                                                                        args))
  end
end

#keyed_url_for(endpoint) ⇒ Object

Builds the <url>('<key>') locator from the tool arguments. The dynamic args[key] lookup has no mutation-testable equivalent (the key is always present, enforced by the SDK's required-argument check), so this is the one MCP subject left on the .mutant.yml ignore list.



102
103
104
105
# File 'lib/odata_duty/mcp_server_builder.rb', line 102

def keyed_url_for(endpoint)
  key = endpoint.entity_type.property_refs.first.name
  ->(args) { "#{endpoint.url}('#{args[key]}')" }
end

#query_options_for(action, args) ⇒ Object

The odata_* aliases only stand in for OData query options on read (:execute) tools — :create/:update/:delete tools' arguments are property values, so a property literally named e.g. odata_select must reach Executor unchanged, not get aliased to $select.



122
123
124
125
126
# File 'lib/odata_duty/mcp_server_builder.rb', line 122

def query_options_for(action, args)
  return args.transform_keys(&:to_s) unless action == :execute

  args.to_h { |key, value| [QUERY_OPTION_SPELLINGS.fetch(key.to_s, key.to_s), value] }
end

#register_collection_tools(server, schema, endpoint) ⇒ Object



36
37
38
39
# File 'lib/odata_duty/mcp_server_builder.rb', line 36

def register_collection_tools(server, schema, endpoint)
  register_list_tool(server, schema, endpoint)
  register_count_tool(server, schema, endpoint) if endpoint.supports_count?
end

#register_count_tool(server, schema, endpoint) ⇒ Object



57
58
59
60
61
62
63
64
# File 'lib/odata_duty/mcp_server_builder.rb', line 57

def register_count_tool(server, schema, endpoint)
  input_schema = McpInputSchemas.count_input_schema(supports_search: endpoint.supports_search?)
  description = tool_description(OperationVerbs.count(endpoint.name), endpoint)
  tool_args = { name: "count_#{endpoint.name}", description: description,
                input_schema: input_schema }
  define_tool(server, schema, :execute,
              url_for: ->(_args) { "#{endpoint.url}/$count" }, **tool_args)
end

#register_create_tool(server, schema, endpoint) ⇒ Object



66
67
68
69
70
71
72
73
# File 'lib/odata_duty/mcp_server_builder.rb', line 66

def register_create_tool(server, schema, endpoint)
  tool_name = "create_#{endpoint.name}"
  input_schema = McpInputSchemas.create_input_schema(endpoint.entity_type)
  description = tool_description(OperationVerbs.create(endpoint.name), endpoint)
  tool_args = { name: tool_name, description: description, input_schema: input_schema }
  McpIdentifierValidator.validate_properties!(endpoint, tool_name, input_schema)
  define_tool(server, schema, :create, url_for: ->(_args) { endpoint.url }, **tool_args)
end

#register_delete_tool(server, schema, endpoint) ⇒ Object



45
46
47
# File 'lib/odata_duty/mcp_server_builder.rb', line 45

def register_delete_tool(server, schema, endpoint)
  register_key_tool(server, schema, endpoint, :delete)
end

#register_endpoint_tools(server, schema, endpoint) ⇒ Object



28
29
30
31
32
33
34
# File 'lib/odata_duty/mcp_server_builder.rb', line 28

def register_endpoint_tools(server, schema, endpoint)
  register_collection_tools(server, schema, endpoint) if endpoint.supports_collection?
  register_get_tool(server, schema, endpoint) if endpoint.supports_individual?
  register_create_tool(server, schema, endpoint) if endpoint.supports_create?
  register_update_tool(server, schema, endpoint) if endpoint.supports_update?
  register_delete_tool(server, schema, endpoint) if endpoint.supports_delete?
end

#register_get_tool(server, schema, endpoint) ⇒ Object



75
76
77
78
79
80
81
82
# File 'lib/odata_duty/mcp_server_builder.rb', line 75

def register_get_tool(server, schema, endpoint)
  tool_name = "get_#{endpoint.name}"
  input_schema = McpInputSchemas.get_input_schema(endpoint.entity_type, tool_name: tool_name)
  description = tool_description(OperationVerbs.get(endpoint.name), endpoint)
  tool_args = { name: tool_name, description: description, input_schema: input_schema }
  McpIdentifierValidator.validate_properties!(endpoint, tool_name, input_schema)
  define_tool(server, schema, :execute, url_for: keyed_url_for(endpoint), **tool_args)
end

#register_key_tool(server, schema, endpoint, action) ⇒ Object



84
85
86
87
88
89
90
91
# File 'lib/odata_duty/mcp_server_builder.rb', line 84

def register_key_tool(server, schema, endpoint, action)
  tool_name = "#{action}_#{endpoint.name}"
  input_schema = McpInputSchemas.public_send("#{action}_input_schema", endpoint.entity_type)
  description = tool_description(OperationVerbs.public_send(action, endpoint.name), endpoint)
  tool_args = { name: tool_name, description: description, input_schema: input_schema }
  McpIdentifierValidator.validate_properties!(endpoint, tool_name, input_schema)
  define_tool(server, schema, action, url_for: keyed_url_for(endpoint), **tool_args)
end

#register_list_tool(server, schema, endpoint) ⇒ Object



49
50
51
52
53
54
55
# File 'lib/odata_duty/mcp_server_builder.rb', line 49

def register_list_tool(server, schema, endpoint)
  input_schema = McpInputSchemas.list_input_schema(supports_search: endpoint.supports_search?)
  description = tool_description(OperationVerbs.list(endpoint.name), endpoint)
  tool_args = { name: "list_#{endpoint.name}", description: description,
                input_schema: input_schema }
  define_tool(server, schema, :execute, url_for: ->(_args) { endpoint.url }, **tool_args)
end

#register_update_tool(server, schema, endpoint) ⇒ Object



41
42
43
# File 'lib/odata_duty/mcp_server_builder.rb', line 41

def register_update_tool(server, schema, endpoint)
  register_key_tool(server, schema, endpoint, :update)
end

#run_tool(action, url:, schema:, context:, query_options:) ⇒ Object

On the .mutant.yml ignore list: e.message has only an equivalent mutant (e), since an OdataDuty::Error renders identically to its message in the text content block.



130
131
132
133
134
135
136
# File 'lib/odata_duty/mcp_server_builder.rb', line 130

def run_tool(action, url:, schema:, context:, query_options:)
  result = Executor.public_send(action, url: url, context: context,
                                        query_options: query_options, schema: schema)
  MCP::Tool::Response.new([{ type: 'text', text: result.to_s }])
rescue OdataDuty::Error => e
  MCP::Tool::Response.new([{ type: 'text', text: e.message }], error: true)
end

#tool_description(verb_text, endpoint) ⇒ Object



93
94
95
96
97
# File 'lib/odata_duty/mcp_server_builder.rb', line 93

def tool_description(verb_text, endpoint)
  return verb_text unless endpoint.description

  "#{verb_text}. #{endpoint.description}"
end