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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +42 -0
- data/lib/graphql_backed/json_schema.rb +310 -0
- data/lib/graphql_backed/mcp.rb +137 -0
- data/lib/graphql_backed/operation.rb +248 -0
- data/lib/graphql_backed/resource.rb +71 -0
- data/lib/graphql_backed/resource_references.rb +64 -0
- data/lib/graphql_backed/rest/app.rb +227 -0
- data/lib/graphql_backed/rest/open_api.rb +179 -0
- data/lib/graphql_backed/rest.rb +181 -0
- data/lib/graphql_backed/service.rb +253 -0
- data/lib/graphql_backed/version.rb +7 -0
- data/lib/graphql_backed.rb +32 -0
- metadata +69 -0
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
|