graphql_backed 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 688cf9549fad3d7eeac442d87b9e24aa3602b8f88d788974f685941c7fc3853a
4
+ data.tar.gz: cc98e73c3013faa2ff629b94baddcc49f8e19b37cb180ec214d1a36dbfb1525a
5
+ SHA512:
6
+ metadata.gz: f0d328339fbd51167750b9cc3f8adc16323f51c3bfefa67efe9bfdd1d5238e60a2f29ed4227aab97b484fdd7fa618257a38a1f0651adf4e1f754bc8a521a3cd2
7
+ data.tar.gz: bb1d9bb8ea64d3bd90adf462ea42ea3b9fc3545bc322449df3077329b542f9918089f346a18175e66f8cec18381d257de30dcaa14d3b715c1dbbb5d5af575e17
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Robert Mosolgo
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # GraphQLBacked
2
+
3
+ Build APIs on top of existing GraphQL APIs.
4
+
5
+ ## Installation
6
+
7
+ ```
8
+ bundle add graphql_backed
9
+ ```
10
+
11
+ ## Documentation
12
+
13
+ API docs are at https://rmosolgo.github.io/graphql_backed/main/:
14
+
15
+ - [`GraphQLBacked::Resource`](https://rmosolgo.github.io/graphql_backed/main/GraphQLBacked/Resource.html): a reusable GraphQL fragment
16
+ - [`GraphQLBacked::Operation`](https://rmosolgo.github.io/graphql_backed/main/GraphQLBacked/Operation.html): a query or mutation which includes resources
17
+ - [`GraphQLBacked::Service`](https://rmosolgo.github.io/graphql_backed/main/GraphQLBacked/Service.html): a set of operations backed by a schema, which runs them
18
+ - [`GraphQLBacked::MCP`](https://rmosolgo.github.io/graphql_backed/main/GraphQLBacked/MCP.html): serves a service's operations as MCP tools
19
+ - [`GraphQLBacked::REST`](https://rmosolgo.github.io/graphql_backed/main/GraphQLBacked/REST.html): serves a service's operations as JSON over HTTP, and describes them with OpenAPI
20
+
21
+ ## Development
22
+
23
+ - `bundle exec rake test` runs the tests
24
+ - `bundle exec rake typecheck` runs Sorbet (`srb tc`)
25
+ - `bundle exec rake rdoc` builds API docs into `doc/`
26
+
27
+ ### Types
28
+
29
+ Type signatures are written as [RBS comments](https://sorbet.org/docs/rbs-support), not `sig` blocks, so there is no runtime dependency on `sorbet-runtime`:
30
+
31
+ ```ruby
32
+ #: (String name) -> Integer
33
+ def lookup(name)
34
+ # ...
35
+ end
36
+ ```
37
+
38
+ After changing dependencies, refresh the gem RBIs with `bin/tapioca gems`.
39
+
40
+ ## License
41
+
42
+ Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,310 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module GraphQLBacked
5
+ # Builds JSON Schema from GraphQL types, for frontends which describe their inputs and outputs that way.
6
+ module JSONSchema # :nodoc:
7
+ # Scalars from the GraphQL specification and those which come with GraphQL-Ruby, as they're accepted in variables.
8
+ # Other scalars accept any JSON value, unless they're configured with Service.scalar_json_schema.
9
+ INPUT_SCALARS = {
10
+ "String" => { "type" => "string" },
11
+ "ID" => { "type" => ["string", "integer"] },
12
+ "Int" => { "type" => "integer" },
13
+ "Float" => { "type" => "number" },
14
+ "Boolean" => { "type" => "boolean" },
15
+ "ISO8601Date" => { "type" => "string", "format" => "date" },
16
+ "ISO8601DateTime" => { "type" => "string", "format" => "date-time" },
17
+ "ISO8601Duration" => { "type" => "string", "format" => "duration" },
18
+ "BigInt" => { "type" => ["string", "integer"] },
19
+ }.freeze #: Hash[String, Hash[String, untyped]]
20
+
21
+ # IDs and BigInts are accepted as integers, but always returned as strings
22
+ OUTPUT_SCALARS = INPUT_SCALARS.merge(
23
+ "ID" => { "type" => "string" },
24
+ "BigInt" => { "type" => "string" },
25
+ ).freeze #: Hash[String, Hash[String, untyped]]
26
+
27
+ # A schema with one of these can't be made nullable by adding `"null"` to its `"type"`:
28
+ # either `null` still wouldn't match, or the result would be harder to read than `anyOf`.
29
+ NOT_NULL_KEYWORDS = ["enum", "const", "properties", "items"].freeze #: Array[String]
30
+
31
+ # A schema for an object whose properties are the variables of `operation_class`.
32
+ # Input objects are put in `$defs` since they may refer to each other.
33
+ #
34
+ # Each variable is described by the first argument that it's passed to, if that argument has a description.
35
+ #
36
+ # `scalars` are schemas for custom scalars, by name. See Service.scalar_json_schema.
37
+ #
38
+ #: (singleton(GraphQL::Schema) schema, singleton(Operation) operation_class, ?scalars: Hash[String, Hash[String, untyped]]) -> Hash[String, untyped]
39
+ def self.input(schema, operation_class, scalars: {})
40
+ Input.new(schema, operation_class, scalars).to_h
41
+ end
42
+
43
+ class Input
44
+ #: (singleton(GraphQL::Schema) schema, singleton(Operation) operation_class, Hash[String, Hash[String, untyped]] scalars) -> void
45
+ def initialize(schema, operation_class, scalars)
46
+ @schema = schema
47
+ @operation_class = operation_class
48
+ @scalars = scalars
49
+ @defs = {} #: Hash[String, untyped]
50
+ end
51
+
52
+ #: -> Hash[String, untyped]
53
+ def to_h
54
+ descriptions = variable_descriptions
55
+ properties = {} #: Hash[String, untyped]
56
+ required = [] #: Array[String]
57
+ @operation_class.definition.variables.each do |variable|
58
+ type = @schema.type_from_ast(variable.type)
59
+ properties[variable.name] = JSONSchema.with_description(type_schema(type), descriptions[variable.name])
60
+ required << variable.name if type.non_null? && variable.default_value.nil?
61
+ end
62
+ result = { "type" => "object", "properties" => properties } #: Hash[String, untyped]
63
+ result["required"] = required if required.any?
64
+ result["$defs"] = @defs if @defs.any?
65
+ result
66
+ end
67
+
68
+ private
69
+
70
+ #: (untyped type) -> Hash[String, untyped]
71
+ def type_schema(type)
72
+ if type.non_null?
73
+ type_schema(type.of_type)
74
+ elsif type.list?
75
+ { "type" => "array", "items" => type_schema(type.of_type) }
76
+ elsif type.kind.enum?
77
+ JSONSchema.with_description({ "type" => "string", "enum" => type.values.keys }, type.description)
78
+ elsif type.kind.input_object?
79
+ @defs[type.graphql_name] ||= begin
80
+ # Assign a placeholder first so that self-referential input objects don't recurse forever
81
+ input_object = @defs[type.graphql_name] = { "type" => "object" } #: Hash[String, untyped]
82
+ required = [] #: Array[String]
83
+ input_object["properties"] = type.arguments.to_h do |name, argument|
84
+ required << name if argument.type.non_null? && !argument.default_value?
85
+ [name, JSONSchema.with_description(type_schema(argument.type), argument.description)]
86
+ end
87
+ input_object["required"] = required if required.any?
88
+ JSONSchema.with_description(input_object, type.description)
89
+ end
90
+ { "$ref" => "#/$defs/#{type.graphql_name}" }
91
+ else
92
+ JSONSchema.scalar(type, INPUT_SCALARS, @scalars)
93
+ end
94
+ end
95
+
96
+ # The description of the first described argument that each variable is passed to, by variable name
97
+ #
98
+ #: -> Hash[String, String?]
99
+ def variable_descriptions
100
+ descriptions = {} #: Hash[String, String?]
101
+ root_type = @schema.root_type_for_operation(@operation_class.operation_type.to_s)
102
+ describe_selections(root_type, @operation_class.definition.selections, descriptions)
103
+ @operation_class.fragments.each_value do |fragment|
104
+ describe_selections(@schema.get_type(fragment.type.name), fragment.selections, descriptions)
105
+ end
106
+ descriptions
107
+ end
108
+
109
+ #: (untyped parent_type, Array[untyped] selections, Hash[String, String?] descriptions) -> void
110
+ def describe_selections(parent_type, selections, descriptions)
111
+ selections.each do |selection|
112
+ case selection
113
+ when GraphQL::Language::Nodes::Field
114
+ field = @schema.get_field(parent_type, selection.name)
115
+ describe_arguments(field, selection.arguments, descriptions)
116
+ describe_directives(selection.directives, descriptions)
117
+ describe_selections(field.type.unwrap, selection.selections, descriptions)
118
+ when GraphQL::Language::Nodes::InlineFragment
119
+ type = selection.type ? @schema.get_type(selection.type.name) : parent_type
120
+ describe_directives(selection.directives, descriptions)
121
+ describe_selections(type, selection.selections, descriptions)
122
+ when GraphQL::Language::Nodes::FragmentSpread
123
+ describe_directives(selection.directives, descriptions)
124
+ end
125
+ end
126
+ end
127
+
128
+ #: (Array[untyped] directives, Hash[String, String?] descriptions) -> void
129
+ def describe_directives(directives, descriptions)
130
+ directives.each do |directive|
131
+ describe_arguments(@schema.directives[directive.name], directive.arguments, descriptions)
132
+ end
133
+ end
134
+
135
+ # `owner` is the field, directive or input object which defines these arguments
136
+ #
137
+ #: (untyped owner, Array[untyped] argument_nodes, Hash[String, String?] descriptions) -> void
138
+ def describe_arguments(owner, argument_nodes, descriptions)
139
+ argument_nodes.each do |argument_node|
140
+ describe_value(owner.get_argument(argument_node.name), argument_node.value, descriptions)
141
+ end
142
+ end
143
+
144
+ #: (untyped argument, untyped value, Hash[String, String?] descriptions) -> void
145
+ def describe_value(argument, value, descriptions)
146
+ case value
147
+ when GraphQL::Language::Nodes::VariableIdentifier
148
+ descriptions[value.name] ||= argument.description
149
+ when GraphQL::Language::Nodes::InputObject
150
+ describe_arguments(argument.type.unwrap, value.arguments, descriptions)
151
+ when Array
152
+ # A variable which is one item of a list isn't described by the argument, which describes the whole list
153
+ value.each { |item| describe_value(argument, item, descriptions) unless item.is_a?(GraphQL::Language::Nodes::VariableIdentifier) }
154
+ end
155
+ end
156
+ end
157
+
158
+ # A schema for the `"data"` of a successful response to `operation_class`.
159
+ # Each fragment (including each Resource) that's always applied where it's spread is put in `$defs`.
160
+ #
161
+ # Fields which are only sometimes present aren't required: those with `@skip` or `@include`, and those
162
+ # selected on a type condition which doesn't match every possible type. There are no per-type variants for
163
+ # interfaces and unions.
164
+ #
165
+ # Each property has the description of its field. `scalars` are schemas for custom scalars, by name.
166
+ #
167
+ #: (singleton(GraphQL::Schema) schema, singleton(Operation) operation_class, ?scalars: Hash[String, Hash[String, untyped]]) -> Hash[String, untyped]
168
+ def self.output(schema, operation_class, scalars: {})
169
+ Output.new(schema, operation_class, scalars).to_h
170
+ end
171
+
172
+ class Output
173
+ #: (singleton(GraphQL::Schema) schema, singleton(Operation) operation_class, Hash[String, Hash[String, untyped]] scalars) -> void
174
+ def initialize(schema, operation_class, scalars)
175
+ @schema = schema
176
+ @operation_class = operation_class
177
+ @scalars = scalars
178
+ @defs = {} #: Hash[String, untyped]
179
+ end
180
+
181
+ #: -> Hash[String, untyped]
182
+ def to_h
183
+ root_type = @schema.root_type_for_operation(@operation_class.operation_type.to_s)
184
+ result = selections_schema(root_type, @operation_class.definition.selections)
185
+ result["$defs"] = @defs if @defs.any?
186
+ result
187
+ end
188
+
189
+ private
190
+
191
+ # The schema for an object of `parent_type` with `selections`
192
+ #
193
+ #: (untyped parent_type, Array[untyped] selections) -> Hash[String, untyped]
194
+ def selections_schema(parent_type, selections)
195
+ properties = {} #: Hash[String, untyped]
196
+ optional = [] #: Array[String]
197
+ refs = [] #: Array[Hash[String, untyped]]
198
+ collect(parent_type, selections, false, properties, optional, refs)
199
+ object = { "type" => "object", "properties" => properties } #: Hash[String, untyped]
200
+ required = properties.keys - optional
201
+ object["required"] = required if required.any?
202
+ if refs.empty?
203
+ object
204
+ elsif properties.empty?
205
+ refs.size == 1 ? refs.first : { "allOf" => refs } #: as !nil
206
+ else
207
+ { "allOf" => [*refs, object] }
208
+ end
209
+ end
210
+
211
+ # Gather the fields of `selections` into `properties`, following fragments.
212
+ # `conditional` is true when these selections might not apply to the object.
213
+ #
214
+ #: (untyped parent_type, Array[untyped] selections, bool conditional, Hash[String, untyped] properties, Array[String] optional, Array[Hash[String, untyped]] refs) -> void
215
+ def collect(parent_type, selections, conditional, properties, optional, refs)
216
+ selections.each do |selection|
217
+ sometimes = conditional || selection.directives.any? { |d| d.name == "skip" || d.name == "include" }
218
+ case selection
219
+ when GraphQL::Language::Nodes::Field
220
+ key = selection.alias || selection.name
221
+ field_schema = if selection.name == "__typename"
222
+ { "type" => "string" }
223
+ else
224
+ field = parent_type.get_field(selection.name)
225
+ JSONSchema.with_description(type_schema(field.type, selection.selections), field.description)
226
+ end
227
+ previous = properties[key]
228
+ # The same key may be selected more than once, with different subselections
229
+ properties[key] = previous && previous != field_schema ? { "allOf" => [previous, field_schema] } : field_schema
230
+ if !sometimes
231
+ optional.delete(key)
232
+ elsif previous.nil?
233
+ optional << key
234
+ end
235
+ when GraphQL::Language::Nodes::InlineFragment
236
+ type = selection.type ? @schema.get_type(selection.type.name) : parent_type
237
+ collect(type, selection.selections, sometimes || !always_applies?(parent_type, type), properties, optional, refs)
238
+ when GraphQL::Language::Nodes::FragmentSpread
239
+ fragment = @operation_class.fragments.fetch(selection.name)
240
+ type = @schema.get_type(fragment.type.name)
241
+ if sometimes || !always_applies?(parent_type, type)
242
+ collect(type, fragment.selections, true, properties, optional, refs)
243
+ else
244
+ @defs[fragment.name] ||= selections_schema(type, fragment.selections)
245
+ ref = { "$ref" => "#/$defs/#{fragment.name}" }
246
+ refs << ref unless refs.include?(ref)
247
+ end
248
+ end
249
+ end
250
+ end
251
+
252
+ # Whether a fragment on `fragment_type` applies to every object that `parent_type` could be
253
+ #
254
+ #: (untyped parent_type, untyped fragment_type) -> bool
255
+ def always_applies?(parent_type, fragment_type)
256
+ parent_type == fragment_type || (@schema.possible_types(parent_type) - @schema.possible_types(fragment_type)).empty?
257
+ end
258
+
259
+ #: (untyped type, Array[untyped] selections) -> Hash[String, untyped]
260
+ def type_schema(type, selections)
261
+ return non_null_schema(type.of_type, selections) if type.non_null?
262
+ json_schema = non_null_schema(type, selections)
263
+ if (json_schema.keys - ["description"]).empty?
264
+ json_schema # already accepts anything
265
+ elsif json_schema.key?("type") && (json_schema.keys & NOT_NULL_KEYWORDS).empty?
266
+ json_schema.merge("type" => [*json_schema["type"], "null"].uniq)
267
+ else
268
+ { "anyOf" => [json_schema, { "type" => "null" }] }
269
+ end
270
+ end
271
+
272
+ #: (untyped type, Array[untyped] selections) -> Hash[String, untyped]
273
+ def non_null_schema(type, selections)
274
+ if type.list?
275
+ { "type" => "array", "items" => type_schema(type.of_type, selections) }
276
+ elsif type.kind.enum?
277
+ JSONSchema.with_description({ "type" => "string", "enum" => type.values.keys }, type.description)
278
+ elsif type.kind.scalar?
279
+ JSONSchema.scalar(type, OUTPUT_SCALARS, @scalars)
280
+ else
281
+ selections_schema(type, selections)
282
+ end
283
+ end
284
+ end
285
+
286
+ # A schema for the scalar `type`: the one in `custom` if there is one, or else the one in `built_in`.
287
+ # Other scalars accept any JSON value.
288
+ #
289
+ #: (untyped type, Hash[String, Hash[String, untyped]] built_in, Hash[String, Hash[String, untyped]] custom) -> Hash[String, untyped]
290
+ def self.scalar(type, built_in, custom)
291
+ name = type.graphql_name
292
+ if (json_schema = custom[name])
293
+ json_schema.key?("description") ? json_schema.dup : with_description(json_schema.dup, type.description)
294
+ elsif (json_schema = built_in[name])
295
+ json_schema.dup
296
+ else
297
+ with_description({}, type.description)
298
+ end
299
+ end
300
+
301
+ # Replaces any description that `json_schema` already has, so a type's description is only used
302
+ # when the field or argument that returns it has none.
303
+ #
304
+ #: (Hash[String, untyped] json_schema, String? description) -> Hash[String, untyped]
305
+ def self.with_description(json_schema, description)
306
+ json_schema["description"] = description if description
307
+ json_schema
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,137 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ module GraphQLBacked
7
+ # Serves a Service's queries and mutations as [Model Context Protocol](https://modelcontextprotocol.io) tools,
8
+ # using the [`mcp` gem](https://github.com/modelcontextprotocol/ruby-sdk). That gem isn't a dependency of this one,
9
+ # so add it to your app (`bundle add mcp`); without it, these methods raise MissingDependencyError.
10
+ #
11
+ # - .transport returns a Streamable HTTP transport, which is a Rack app
12
+ # - .server returns the `MCP::Server`, for other transports (like stdio)
13
+ # - .tools returns the `MCP::Tool` classes
14
+ #
15
+ # Each of them is also available from the service, as Service.mcp_transport, Service.mcp_server and Service.mcp_tools:
16
+ #
17
+ # # config.ru
18
+ # run MyService.mcp_transport(allowed_hosts: ["api.example.com"])
19
+ #
20
+ # ## Tools
21
+ #
22
+ # Each Operation of the service becomes a tool:
23
+ #
24
+ # - its name is Operation.public_name
25
+ # - its description is Operation.description, or else the description of its root field in the schema
26
+ # - its hints come from Operation.query?, Operation.destructive?, Operation.idempotent? and Operation.open_world?
27
+ # - its input is the operation's variables, each described by the argument that it's passed to
28
+ # - its output schema describes the `"data"` of the GraphQL response, based on the operation's selections,
29
+ # with the description of each field and each Resource as a reusable definition
30
+ # - custom scalars are described by Service.scalar_json_schema
31
+ # - its result is the `"data"` of the GraphQL response. If the response has `"errors"`,
32
+ # the tool result is flagged as an error and contains the whole response instead.
33
+ module MCP
34
+ # The `MCP::Tool` classes for `service`, one for each of its operations. See the "Tools" section above for how they're built.
35
+ #
36
+ # This builds new classes each time; Service.mcp_tools keeps them until the service changes.
37
+ #
38
+ #: (singleton(Service) service) -> Array[singleton(::MCP::Tool)]
39
+ def self.tools(service)
40
+ require_mcp
41
+ service.operations.map { |operation_class| build_tool(service, operation_class) }
42
+ end
43
+
44
+ # An `MCP::Server` with the tools of `service` (from Service.mcp_tools), for use with any of the `mcp` gem's transports (for example, stdio).
45
+ # `context` is the GraphQL context which its tools execute with; other options are passed to `MCP::Server.new`:
46
+ #
47
+ # GraphQLBacked::MCP.server(MyService, context: { current_user: user }, name: "my-api", version: "1.2.0")
48
+ #
49
+ # By default, the server is named after the service.
50
+ #
51
+ #: (singleton(Service) service, ?context: Hash[untyped, untyped], **untyped server_options) -> ::MCP::Server
52
+ def self.server(service, context: {}, **server_options)
53
+ service_tools = service.mcp_tools # this also makes sure that `mcp` is loaded
54
+ ::MCP::Server.new(
55
+ name: service.name || "GraphQLBacked",
56
+ capabilities: { tools: {} },
57
+ **server_options,
58
+ tools: service_tools,
59
+ server_context: { graphql_context: context },
60
+ )
61
+ end
62
+
63
+ # A Streamable HTTP transport for the .server of `service`, which is also a Rack app
64
+ # (so your app needs the `rack` gem as well as `mcp`):
65
+ #
66
+ # # config.ru
67
+ # run GraphQLBacked::MCP.transport(MyService, allowed_hosts: ["api.example.com"])
68
+ #
69
+ # `context` is the GraphQL context to execute with and `server_options` are passed to `MCP::Server.new`
70
+ # (see .server); other options are passed to `MCP::Server::Transports::StreamableHTTPTransport.new`:
71
+ #
72
+ # GraphQLBacked::MCP.transport(MyService, server_options: { name: "my-api", version: "1.2.0" }, stateless: true)
73
+ #
74
+ # To use a different GraphQL context for each request, build a transport for each request,
75
+ # for example in a Rails controller:
76
+ #
77
+ # def create
78
+ # transport = MyService.mcp_transport(context: { current_user: current_user }, stateless: true, enable_json_response: true)
79
+ # status, headers, body = transport.call(request.env)
80
+ # # ...
81
+ # end
82
+ #
83
+ #: (singleton(Service) service, ?context: Hash[untyped, untyped], ?server_options: Hash[Symbol, untyped], **untyped transport_options) -> ::MCP::Server::Transports::StreamableHTTPTransport
84
+ def self.transport(service, context: {}, server_options: {}, **transport_options)
85
+ mcp_server = server(service, context: context, **server_options)
86
+ ::MCP::Server::Transports::StreamableHTTPTransport.new(mcp_server, **transport_options)
87
+ end
88
+
89
+ # `mcp` isn't a dependency of this gem, so it's only required when MCP features are first used.
90
+ #
91
+ #: -> void
92
+ def self.require_mcp # :nodoc:
93
+ require "mcp"
94
+ rescue LoadError => err
95
+ raise unless err.path == "mcp"
96
+ raise MissingDependencyError, "GraphQLBacked's MCP support requires the `mcp` gem, add it to your app with `bundle add mcp`"
97
+ end
98
+
99
+ #: (singleton(Service) service, singleton(Operation) operation_class) -> singleton(::MCP::Tool)
100
+ def self.build_tool(service, operation_class) # :nodoc:
101
+ scalars = service.scalar_json_schemas
102
+ ::MCP::Tool.define(
103
+ name: operation_class.public_name,
104
+ description: operation_class.description || root_field_description(service, operation_class),
105
+ input_schema: JSONSchema.input(service.schema, operation_class, scalars: scalars),
106
+ output_schema: JSONSchema.output(service.schema, operation_class, scalars: scalars),
107
+ annotations: {
108
+ read_only_hint: operation_class.query?,
109
+ destructive_hint: operation_class.destructive?,
110
+ idempotent_hint: operation_class.idempotent?,
111
+ open_world_hint: operation_class.open_world?,
112
+ },
113
+ ) do |server_context:, **arguments|
114
+ response = service.execute(
115
+ operation_class,
116
+ variables: JSON.parse(JSON.generate(arguments)), # the `mcp` gem symbolizes keys, GraphQL wants strings
117
+ context: server_context[:graphql_context] || {},
118
+ )
119
+ data = response["data"]
120
+ if response.key?("errors")
121
+ ::MCP::Tool::Response.new([{ type: "text", text: JSON.generate(response) }], error: true)
122
+ else
123
+ # `data` rather than the root field's value, because structured content must be an object
124
+ ::MCP::Tool::Response.new([{ type: "text", text: JSON.generate(data) }], structured_content: data)
125
+ end
126
+ end
127
+ end
128
+
129
+ #: (singleton(Service) service, singleton(Operation) operation_class) -> String?
130
+ def self.root_field_description(service, operation_class) # :nodoc:
131
+ root_type = service.schema.root_type_for_operation(operation_class.operation_type.to_s)
132
+ root_type.get_field(operation_class.root_field_name)&.description
133
+ end
134
+
135
+ private_class_method :require_mcp, :build_tool, :root_field_description
136
+ end
137
+ end