explicit 0.2.15 → 0.2.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +144 -9
- data/app/views/explicit/documentation/type/_big_decimal.html.erb +16 -0
- data/config/locales/en.yml +1 -0
- data/lib/explicit/documentation/builder.rb +1 -1
- data/lib/explicit/mcp_server/builder.rb +50 -0
- data/lib/explicit/mcp_server/request.rb +42 -0
- data/lib/explicit/mcp_server/response.rb +29 -0
- data/lib/explicit/mcp_server/router.rb +84 -0
- data/lib/explicit/mcp_server/tool.rb +20 -0
- data/lib/explicit/mcp_server.rb +39 -0
- data/lib/explicit/request.rb +35 -1
- data/lib/explicit/type/agreement.rb +7 -9
- data/lib/explicit/type/any.rb +4 -4
- data/lib/explicit/type/array.rb +6 -8
- data/lib/explicit/type/big_decimal.rb +35 -14
- data/lib/explicit/type/boolean.rb +4 -6
- data/lib/explicit/type/date.rb +9 -11
- data/lib/explicit/type/date_range.rb +11 -13
- data/lib/explicit/type/date_time_iso8601.rb +9 -11
- data/lib/explicit/type/date_time_iso8601_range.rb +10 -12
- data/lib/explicit/type/date_time_unix_epoch.rb +9 -11
- data/lib/explicit/type/enum.rb +5 -7
- data/lib/explicit/type/file.rb +9 -11
- data/lib/explicit/type/float.rb +12 -14
- data/lib/explicit/type/hash.rb +8 -10
- data/lib/explicit/type/integer.rb +12 -14
- data/lib/explicit/type/literal.rb +5 -7
- data/lib/explicit/type/one_of.rb +3 -5
- data/lib/explicit/type/record.rb +12 -25
- data/lib/explicit/type/string.rb +11 -13
- data/lib/explicit/type.rb +10 -2
- data/lib/explicit/version.rb +1 -1
- data/lib/explicit.rb +7 -0
- metadata +9 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6ea020d82544c742432038439630cad304e496fb33335e44de226db513feacbe
|
4
|
+
data.tar.gz: f733ee198a502d8962e5e726527153f28e924e275ddbe7f965e7b8dc89ee74f9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 31c95c4c1751c84cc4e02a2141bc209005700ac8aba906caecfbaf38193e92d43ad42832bd37a844487ccfc0f7ed433689e078545baf5b35d2db267bff419eff
|
7
|
+
data.tar.gz: fcbdf61db1ed115b8d0e8c28ff0ba7ea434f4c6c4096dda5e3b238777709a2797d4279906deba7cea11364b90ee2bb43bfeb4b846aad12098b1f2ce84ae6c0de
|
data/README.md
CHANGED
@@ -14,7 +14,10 @@ documented types at runtime.
|
|
14
14
|
5. [Writing tests](#writing-tests)
|
15
15
|
6. [Publishing documentation](#publishing-documentation)
|
16
16
|
- [Adding request examples](#adding-request-examples)
|
17
|
-
7.
|
17
|
+
7. [MCP](#mcp)
|
18
|
+
- [Tool configuration](#tool-configuration)
|
19
|
+
- [Security](#security-and-authorization)
|
20
|
+
8. Types
|
18
21
|
- [Agreement](#agreement)
|
19
22
|
- [Any](#any)
|
20
23
|
- [Array](#array)
|
@@ -37,7 +40,7 @@ documented types at runtime.
|
|
37
40
|
- [One of](#one-of)
|
38
41
|
- [Record](#record)
|
39
42
|
- [String](#string)
|
40
|
-
|
43
|
+
9. [Configuration](#configuration)
|
41
44
|
- [Changing examples file path](#changing-examples-file-path)
|
42
45
|
- [Customizing error messages](#customizing-error-messages)
|
43
46
|
- [Customizing error serialization](#customizing-error-serialization)
|
@@ -75,7 +78,7 @@ available:
|
|
75
78
|
- `description: "text"` - Adds a documentation to the param. Markdown supported.
|
76
79
|
- `response(status, type)` - Adds a response type. You can add multiple
|
77
80
|
responses with different formats.
|
78
|
-
- `
|
81
|
+
- `example(params:, headers:, response:)` - Adds an example to the
|
79
82
|
documentation. [See more details here](#adding-request-examples).
|
80
83
|
- `base_url(url)` - Sets the host for this API. For example: "https://api.myapp.com".
|
81
84
|
Meant to be used with [request composition](#reusing-requests).
|
@@ -314,12 +317,12 @@ end
|
|
314
317
|
|
315
318
|
You can add request examples in two different ways:
|
316
319
|
|
317
|
-
1. Manually add an example with `
|
320
|
+
1. Manually add an example with `example(params:, headers:, response:)`
|
318
321
|
2. Automatically save examples from tests
|
319
322
|
|
320
323
|
### 1. Manually adding examples
|
321
324
|
|
322
|
-
In a request, call `
|
325
|
+
In a request, call `example(params:, headers:, response:)` after declaring
|
323
326
|
params and responses. It's important the example comes after params and
|
324
327
|
responses to make sure it actually follows the type definition.
|
325
328
|
|
@@ -329,7 +332,7 @@ For example:
|
|
329
332
|
Request = Explicit::Request.new do
|
330
333
|
# ... other configs, params and responses
|
331
334
|
|
332
|
-
|
335
|
+
example(
|
333
336
|
params: {
|
334
337
|
name: "Bilbo baggins",
|
335
338
|
email: "bilbo@shire.com",
|
@@ -356,8 +359,8 @@ way you like. For example:
|
|
356
359
|
Request = Explicit::Request.new do
|
357
360
|
# ... other configs, params and responses
|
358
361
|
|
359
|
-
|
360
|
-
|
362
|
+
example MyApp::Examples::REQUEST_1
|
363
|
+
example MyApp::Examples::REQUEST_2
|
361
364
|
end
|
362
365
|
```
|
363
366
|
|
@@ -397,6 +400,137 @@ Whenever you wish to refresh the examples file run the test suite with the ENV
|
|
397
400
|
**Important: be careful not to leak any sensitive data when persisting
|
398
401
|
examples from tests**
|
399
402
|
|
403
|
+
# MCP
|
404
|
+
|
405
|
+
You can expose your API endpoints as tools for chat clients by mounting an MCP
|
406
|
+
server. The MCP server acts as a proxy receiving tool calls and forwarding them
|
407
|
+
to your existing REST API controllers. Your controllers remain the source of
|
408
|
+
truth and the MCP server simply provides a tool-compatible interface.
|
409
|
+
|
410
|
+
To build an MCP server, instantiate `::Explicit::MCPServer` and add the requests
|
411
|
+
you wish to expose. The following methods are available:
|
412
|
+
|
413
|
+
- `name(str)` - Sets the name of the MCP Server which is displayed in the MCP
|
414
|
+
client.
|
415
|
+
- `version(str)` - Sets the version of the MCP server which is displayed in the
|
416
|
+
MCP client
|
417
|
+
- `add(request)` - Exposes a request as a tool in the MCP server.
|
418
|
+
|
419
|
+
For example:
|
420
|
+
|
421
|
+
```ruby
|
422
|
+
module MyApp::API::V1
|
423
|
+
MCPServer = Explicit::MCPServer.new do
|
424
|
+
name "My app"
|
425
|
+
version "1.0.0"
|
426
|
+
|
427
|
+
add ArticlesController::CreateRequest
|
428
|
+
add ArticlesController::UpdateRequest
|
429
|
+
add ArticlesController::DestroyRequest
|
430
|
+
|
431
|
+
def authorize(**)
|
432
|
+
true
|
433
|
+
end
|
434
|
+
end
|
435
|
+
end
|
436
|
+
```
|
437
|
+
|
438
|
+
Then, mount the MCP Server in your `routes.rb`:
|
439
|
+
|
440
|
+
```ruby
|
441
|
+
Rails.application.routes.draw do
|
442
|
+
mount MyApp::API::V1::MCPServer => "/api/v1/mcp"
|
443
|
+
end
|
444
|
+
```
|
445
|
+
|
446
|
+
### Tool configuration
|
447
|
+
|
448
|
+
The following methods are available in `Explicit::Request` to configure the MCP
|
449
|
+
tool. They're all optional and the MCP server still works correctly using the
|
450
|
+
request's default title, description and params.
|
451
|
+
|
452
|
+
- `mcp_tool_name(name)` - Sets the unique identifier for the tool. Should be a
|
453
|
+
string with only ASCII letters, numbers and underscore. By default it is set
|
454
|
+
to a normalized version of the route's path.
|
455
|
+
- `mcp_tool_description(description)` - Sets the description of the tool.
|
456
|
+
Markdown supported. By default it is set to the request description.
|
457
|
+
- `mcp_tool_title(title)` - Sets the human readable name for the tool. By
|
458
|
+
default it is set to the request's title.
|
459
|
+
- `mcp_tool_read_only_hint(true/false)` - If true, the tool does not modify its
|
460
|
+
environment.
|
461
|
+
- `mcp_tool_destructive_hint(true/false)` - If true, the tool may perform destructive
|
462
|
+
updates.
|
463
|
+
- `mcp_tool_idempotent_hint(true/false)` - If true, repeated calls with same args
|
464
|
+
have no additional effect.
|
465
|
+
- `mcp_tool_open_world_hint(true/false)` - If true, tool interacts with external
|
466
|
+
entities.
|
467
|
+
|
468
|
+
For example:
|
469
|
+
|
470
|
+
```ruby
|
471
|
+
Request = Explicit::Request.new do
|
472
|
+
# ... other request config
|
473
|
+
|
474
|
+
mcp_tool_name "get_article_by_id"
|
475
|
+
mcp_tool_title "Get article by id"
|
476
|
+
mcp_tool_read_only_hint true
|
477
|
+
mcp_tool_destructive_hint false
|
478
|
+
mcp_tool_idempotent_hint true
|
479
|
+
mcp_tool_open_world_hint false
|
480
|
+
|
481
|
+
mcp_tool_description <<~TEXT
|
482
|
+
Finds the article by the specified id and returns the title, body and
|
483
|
+
published_at date.
|
484
|
+
TEXT
|
485
|
+
end
|
486
|
+
```
|
487
|
+
|
488
|
+
### Security
|
489
|
+
|
490
|
+
There are two considerations when securing your MCP server:
|
491
|
+
|
492
|
+
1. **Authorize the MCP tool call**
|
493
|
+
You should authorize the action based on a unique attribute present in the
|
494
|
+
request's params or headers. For example, you should share a URL with your
|
495
|
+
customers similar to this one:
|
496
|
+
`https://myapp.com/api/v1/mcp?key=d17c08d5-968c-497f-8db2-ec958d45b447`.
|
497
|
+
Then, in the `authorize` method, you'd use the `key` to find the
|
498
|
+
user/customer/account.
|
499
|
+
2. **Authenticate the REST API**
|
500
|
+
Your API probably has an authentication mechanism that is different from the
|
501
|
+
MCP server, such as bearer tokens specified in request headers. To
|
502
|
+
authenticate the API you can either 1) use `proxy_with(headers:)` or 2)
|
503
|
+
share the current user using `ActiveSupport::CurrentAttributes`.
|
504
|
+
|
505
|
+
To secure your MCPServer you must implement the `authorize` method in your
|
506
|
+
`Explicit::MCPServer`. This method is invoked on all requests received by the
|
507
|
+
MCP server. The following arguments are given to `authorize`:
|
508
|
+
|
509
|
+
* `params` - hash with request's query string values
|
510
|
+
* `headers` - hash with the request's HTTP headers
|
511
|
+
|
512
|
+
If you return `false` then the request will be rejected immediatly without ever
|
513
|
+
hitting your API controllers. For example:
|
514
|
+
|
515
|
+
```ruby
|
516
|
+
module MyApp::API::V1
|
517
|
+
MCPServer = Explicit::MCPServer.new do
|
518
|
+
# ... other configurations
|
519
|
+
|
520
|
+
def authorize(params:, headers:)
|
521
|
+
user = ::User.find_by(api_key: params[:key])
|
522
|
+
return false if user.blank?
|
523
|
+
|
524
|
+
# 1) proxy the request to controllers with headers
|
525
|
+
proxy_with headers: { "Authorization" => "Bearer #{user.api_key}" }
|
526
|
+
|
527
|
+
# 2) or share the user with controllers using ActiveSupport::CurrentAttributes
|
528
|
+
Current.user = user
|
529
|
+
end
|
530
|
+
end
|
531
|
+
end
|
532
|
+
```
|
533
|
+
|
400
534
|
# Types
|
401
535
|
|
402
536
|
### Agreement
|
@@ -418,7 +552,6 @@ and `1`.
|
|
418
552
|
Allows all values, including null. Useful when documenting a proxy that
|
419
553
|
responds with whatever value the other service returned.
|
420
554
|
|
421
|
-
|
422
555
|
### Array
|
423
556
|
|
424
557
|
```ruby
|
@@ -434,6 +567,8 @@ value is invalid then the array is invalid.
|
|
434
567
|
|
435
568
|
```ruby
|
436
569
|
:big_decimal
|
570
|
+
[:big_decimal, negative: false]
|
571
|
+
[:big_decimal, positive: true]
|
437
572
|
[:big_decimal, min: 0] # inclusive
|
438
573
|
[:big_decimal, max: 100] # inclusive
|
439
574
|
```
|
@@ -5,4 +5,20 @@
|
|
5
5
|
<%= type_constraints do %>
|
6
6
|
<%= type_constraint "min:", type.min if type.min.present? %>
|
7
7
|
<%= type_constraint "max:", type.max if type.max.present? %>
|
8
|
+
|
9
|
+
<% if type.negative == false %>
|
10
|
+
<%= type_constraint "not", "negative" %>
|
11
|
+
<% end %>
|
12
|
+
|
13
|
+
<% if type.negative == true %>
|
14
|
+
<%= type_constraint "only", "negative" %>
|
15
|
+
<% end %>
|
16
|
+
|
17
|
+
<% if type.positive == false %>
|
18
|
+
<%= type_constraint "not", "positive" %>
|
19
|
+
<% end %>
|
20
|
+
|
21
|
+
<% if type.positive == true %>
|
22
|
+
<%= type_constraint "only", "positive" %>
|
23
|
+
<% end %>
|
8
24
|
<% end %>
|
data/config/locales/en.yml
CHANGED
@@ -51,6 +51,7 @@ en:
|
|
51
51
|
format: "must have format %{regex}"
|
52
52
|
swagger:
|
53
53
|
agreement: "* Must be accepted (true)"
|
54
|
+
big_decimal_format: "* String-encoded decimal number. For example: '123.45'"
|
54
55
|
big_decimal_min: "* Minimum: %{min}"
|
55
56
|
big_decimal_max: "* Maximum: %{max}"
|
56
57
|
date_range: '* The value must be a range between two dates in the format of: "YYYY-MM-DD..YYYY-MM-DD"'
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Explicit::MCPServer::Builder
|
4
|
+
def initialize
|
5
|
+
@tools = []
|
6
|
+
end
|
7
|
+
|
8
|
+
def name(name) = (@name = name)
|
9
|
+
def get_name = @name
|
10
|
+
|
11
|
+
def version(version) = (@version = version)
|
12
|
+
def get_version = @version
|
13
|
+
|
14
|
+
def add(request)
|
15
|
+
@tools << ::Explicit::MCPServer::Tool.new(request)
|
16
|
+
end
|
17
|
+
|
18
|
+
def call(env)
|
19
|
+
request = ::Explicit::MCPServer::Request.from_rack_env(env)
|
20
|
+
|
21
|
+
if respond_to?(:authorize)
|
22
|
+
params = ::Rack::Utils.parse_nested_query(env["QUERY_STRING"]).with_indifferent_access
|
23
|
+
|
24
|
+
case authorize(params:, headers: request.headers)
|
25
|
+
in { headers: }
|
26
|
+
request.headers.merge!(headers)
|
27
|
+
in false
|
28
|
+
return [403, {}, []]
|
29
|
+
else
|
30
|
+
nil
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
response = router.handle(request)
|
35
|
+
|
36
|
+
[200, { "Content-Type" => "application/json" }, [response.to_json]]
|
37
|
+
end
|
38
|
+
|
39
|
+
def router
|
40
|
+
@router ||= ::Explicit::MCPServer::Router.new(
|
41
|
+
name: @name,
|
42
|
+
version: @version,
|
43
|
+
tools: @tools
|
44
|
+
)
|
45
|
+
end
|
46
|
+
|
47
|
+
def proxy_with(headers:)
|
48
|
+
{ headers: }
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack/utils"
|
4
|
+
|
5
|
+
module Explicit::MCPServer
|
6
|
+
Request = ::Data.define(:id, :method, :params, :host, :headers) do
|
7
|
+
def self.from_rack_env(env)
|
8
|
+
headers = env.each_with_object({}) do |(key, value), hash|
|
9
|
+
if key.start_with?("HTTP_") && key != "HTTP_HOST"
|
10
|
+
header_name = key[5..-1].split("_").map(&:capitalize).join("-")
|
11
|
+
hash[header_name] = value
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
body = ::JSON.parse(env["rack.input"].read)
|
16
|
+
|
17
|
+
new(
|
18
|
+
id: body["id"],
|
19
|
+
method: body["method"],
|
20
|
+
params: body["params"],
|
21
|
+
host: env["HTTP_HOST"],
|
22
|
+
headers:
|
23
|
+
)
|
24
|
+
rescue ::JSON::ParserError
|
25
|
+
new(
|
26
|
+
id: nil,
|
27
|
+
method: nil,
|
28
|
+
params: nil,
|
29
|
+
host: env["HTTP_HOST"],
|
30
|
+
headers: headers
|
31
|
+
)
|
32
|
+
end
|
33
|
+
|
34
|
+
def result(value)
|
35
|
+
::Explicit::MCPServer::Response::Result.new(id:, value:)
|
36
|
+
end
|
37
|
+
|
38
|
+
def error(value)
|
39
|
+
::Explicit::MCPServer::Response::Error.new(id:, value:)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Explicit::MCPServer::Response
|
4
|
+
Result = Data.define(:id, :value) do
|
5
|
+
def result? = true
|
6
|
+
def error? = false
|
7
|
+
|
8
|
+
def to_json
|
9
|
+
{
|
10
|
+
jsonrpc: "2.0",
|
11
|
+
id:,
|
12
|
+
result: value
|
13
|
+
}.to_json
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
Error = Data.define(:id, :value) do
|
18
|
+
def result? = false
|
19
|
+
def error? = true
|
20
|
+
|
21
|
+
def to_json
|
22
|
+
{
|
23
|
+
jsonrpc: "2.0",
|
24
|
+
id:,
|
25
|
+
error: value
|
26
|
+
}.to_json
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Explicit::MCPServer::Router
|
4
|
+
def initialize(name:, version:, tools:)
|
5
|
+
@name = name
|
6
|
+
@version = version
|
7
|
+
@tools = tools.index_by { _1.request.get_mcp_tool_name }
|
8
|
+
end
|
9
|
+
|
10
|
+
def handle(request)
|
11
|
+
case request.method
|
12
|
+
when "ping" then noop(request)
|
13
|
+
when "initialize" then initialize_(request)
|
14
|
+
when "notifications/initialized" then noop(request)
|
15
|
+
when "tools/list" then tools_list(request)
|
16
|
+
when "tools/call" then tools_call(request)
|
17
|
+
else noop(request)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def noop(request)
|
24
|
+
request.result({})
|
25
|
+
end
|
26
|
+
|
27
|
+
def initialize_(request)
|
28
|
+
request.result({
|
29
|
+
protocolVersion: "2024-11-05",
|
30
|
+
capabilities: {
|
31
|
+
tools: {
|
32
|
+
listChanged: false
|
33
|
+
}
|
34
|
+
},
|
35
|
+
serverInfo: {
|
36
|
+
name: @name,
|
37
|
+
version: @version
|
38
|
+
}
|
39
|
+
})
|
40
|
+
end
|
41
|
+
|
42
|
+
def tools_list(request)
|
43
|
+
request.result({ tools: @tools.values.map(&:serialize) })
|
44
|
+
end
|
45
|
+
|
46
|
+
def tools_call(request)
|
47
|
+
tool_name = request.params["name"]
|
48
|
+
arguments = request.params["arguments"]
|
49
|
+
|
50
|
+
tool = @tools[tool_name]
|
51
|
+
|
52
|
+
if !tool
|
53
|
+
return request.error({ code: -32602, message: "tool not found" })
|
54
|
+
end
|
55
|
+
|
56
|
+
session = ::ActionDispatch::Integration::Session.new(::Rails.application)
|
57
|
+
session.host = request.host
|
58
|
+
route = tool.request.routes.first
|
59
|
+
|
60
|
+
path = [
|
61
|
+
tool.request.get_base_path,
|
62
|
+
route.replace_path_params(arguments)
|
63
|
+
].compact_blank.join
|
64
|
+
|
65
|
+
path, params =
|
66
|
+
if route.accepts_request_body?
|
67
|
+
[path, arguments]
|
68
|
+
else
|
69
|
+
["#{path}?#{arguments.to_query}", nil]
|
70
|
+
end
|
71
|
+
|
72
|
+
session.process(route.method, path, params:, headers: request.headers)
|
73
|
+
|
74
|
+
request.result({
|
75
|
+
content: [
|
76
|
+
{
|
77
|
+
type: "text",
|
78
|
+
text: session.response.body
|
79
|
+
}
|
80
|
+
],
|
81
|
+
isError: session.response.status < 200 || session.response.status > 299
|
82
|
+
})
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Explicit::MCPServer
|
4
|
+
Tool = ::Data.define(:request) do
|
5
|
+
def serialize
|
6
|
+
{
|
7
|
+
name: request.get_mcp_tool_name,
|
8
|
+
description: request.get_mcp_tool_description,
|
9
|
+
inputSchema: request.params_type.mcp_schema,
|
10
|
+
annotations: {
|
11
|
+
title: request.get_mcp_tool_title,
|
12
|
+
readOnlyHint: request.get_mcp_tool_read_only_hint,
|
13
|
+
destructiveHint: request.get_mcp_tool_destructive_hint,
|
14
|
+
idempotentHint: request.get_mcp_tool_idempotent_hint,
|
15
|
+
openWorldHint: request.get_mcp_tool_open_world_hint
|
16
|
+
}.compact
|
17
|
+
}.compact
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Explicit::MCPServer
|
4
|
+
extend self
|
5
|
+
|
6
|
+
def new(&block)
|
7
|
+
engine = ::Class.new(::Rails::Engine)
|
8
|
+
|
9
|
+
builder = Builder.new.tap do |builder|
|
10
|
+
builder.instance_eval(&block)
|
11
|
+
end
|
12
|
+
|
13
|
+
if builder.get_name.blank?
|
14
|
+
raise <<~TEXT
|
15
|
+
MCP servers must have a name. For example:
|
16
|
+
|
17
|
+
Explicit::MCPServer.new do
|
18
|
+
name "My app"
|
19
|
+
end
|
20
|
+
TEXT
|
21
|
+
end
|
22
|
+
|
23
|
+
if builder.get_version.blank?
|
24
|
+
raise <<~TEXT
|
25
|
+
MCP servers must have a version. For example:
|
26
|
+
|
27
|
+
Explicit::MCPServer.new do
|
28
|
+
version "1.0.0"
|
29
|
+
end
|
30
|
+
TEXT
|
31
|
+
end
|
32
|
+
|
33
|
+
engine.routes.draw do
|
34
|
+
match "/", to: builder, as: :explicit_mcp, via: :all
|
35
|
+
end
|
36
|
+
|
37
|
+
engine
|
38
|
+
end
|
39
|
+
end
|
data/lib/explicit/request.rb
CHANGED
@@ -68,6 +68,39 @@ class Explicit::Request
|
|
68
68
|
def description(markdown) = (@description = markdown)
|
69
69
|
def get_description = @description
|
70
70
|
|
71
|
+
concerning :MCP do
|
72
|
+
def mcp_tool_name(name) = (@mcp_tool_name = name)
|
73
|
+
def get_mcp_tool_name
|
74
|
+
return @mcp_tool_name if @mcp_tool_name.present?
|
75
|
+
|
76
|
+
normalized = @routes.first.method.to_s + @routes.first.path.gsub(/\//, "_")
|
77
|
+
|
78
|
+
@routes.first.params.each do |param_name|
|
79
|
+
normalized = normalized.gsub(":#{param_name}", "by_#{param_name}")
|
80
|
+
end
|
81
|
+
|
82
|
+
normalized
|
83
|
+
end
|
84
|
+
|
85
|
+
def mcp_tool_description(markdown) = (@mcp_tool_description = markdown)
|
86
|
+
def get_mcp_tool_description = @mcp_tool_description || get_description
|
87
|
+
|
88
|
+
def mcp_tool_title(title) = (@mcp_tool_title = title)
|
89
|
+
def get_mcp_tool_title = @mcp_tool_title || get_title
|
90
|
+
|
91
|
+
def mcp_tool_read_only_hint(bool) = (@mcp_tool_read_only_hint = bool)
|
92
|
+
def get_mcp_tool_read_only_hint = @mcp_tool_read_only_hint
|
93
|
+
|
94
|
+
def mcp_tool_destructive_hint(bool) = (@mcp_tool_destructive_hint = bool)
|
95
|
+
def get_mcp_tool_destructive_hint = @mcp_tool_destructive_hint
|
96
|
+
|
97
|
+
def mcp_tool_idempotent_hint(bool) = (@mcp_tool_idempotent_hint = bool)
|
98
|
+
def get_mcp_tool_idempotent_hint = @mcp_tool_idempotent_hint
|
99
|
+
|
100
|
+
def mcp_tool_open_world_hint(bool) = (@mcp_tool_open_world_hint = bool)
|
101
|
+
def get_mcp_tool_open_world_hint = @mcp_tool_open_world_hint
|
102
|
+
end
|
103
|
+
|
71
104
|
def header(name, type, **options)
|
72
105
|
raise ArgumentError("duplicated header #{name}") if @headers.key?(name)
|
73
106
|
|
@@ -108,7 +141,7 @@ class Explicit::Request
|
|
108
141
|
@responses[status] << typespec
|
109
142
|
end
|
110
143
|
|
111
|
-
def
|
144
|
+
def example(params:, response:, headers: {})
|
112
145
|
raise ArgumentError.new("missing :status in response") if !response.key?(:status)
|
113
146
|
raise ArgumentError.new("missing :data in response") if !response.key?(:data)
|
114
147
|
|
@@ -130,6 +163,7 @@ class Explicit::Request
|
|
130
163
|
|
131
164
|
@examples[response.status] << Example.new(request: self, params:, headers:, response:)
|
132
165
|
end
|
166
|
+
alias add_example example
|
133
167
|
|
134
168
|
def validate!(values)
|
135
169
|
case params_type.validate(values)
|
@@ -26,14 +26,12 @@ class Explicit::Type::Agreement < Explicit::Type
|
|
26
26
|
end
|
27
27
|
end
|
28
28
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
})
|
37
|
-
end
|
29
|
+
def json_schema(flavour)
|
30
|
+
{
|
31
|
+
type: "boolean",
|
32
|
+
description_topics: [
|
33
|
+
swagger_i18n("agreement")
|
34
|
+
]
|
35
|
+
}
|
38
36
|
end
|
39
37
|
end
|
data/lib/explicit/type/any.rb
CHANGED
data/lib/explicit/type/array.rb
CHANGED
@@ -43,13 +43,11 @@ class Explicit::Type::Array < Explicit::Type
|
|
43
43
|
end
|
44
44
|
end
|
45
45
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
})
|
53
|
-
end
|
46
|
+
def json_schema(flavour)
|
47
|
+
{
|
48
|
+
type: "array",
|
49
|
+
items: item_type.mcp_schema,
|
50
|
+
minItems: empty ? 0 : 1
|
51
|
+
}
|
54
52
|
end
|
55
53
|
end
|
@@ -1,11 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
class Explicit::Type::BigDecimal < Explicit::Type
|
4
|
-
attr_reader :min, :max
|
4
|
+
attr_reader :min, :max, :negative, :positive
|
5
5
|
|
6
|
-
def initialize(min: nil, max: nil)
|
6
|
+
def initialize(min: nil, max: nil, negative: nil, positive: nil)
|
7
7
|
@min = min
|
8
8
|
@max = max
|
9
|
+
@negative = negative
|
10
|
+
@positive = positive
|
9
11
|
end
|
10
12
|
|
11
13
|
def validate(value)
|
@@ -23,6 +25,22 @@ class Explicit::Type::BigDecimal < Explicit::Type
|
|
23
25
|
return error_i18n("max", max:)
|
24
26
|
end
|
25
27
|
|
28
|
+
if negative == false && decimal_value < 0
|
29
|
+
return error_i18n("not_negative")
|
30
|
+
end
|
31
|
+
|
32
|
+
if negative == true && decimal_value >= 0
|
33
|
+
return error_i18n("only_negative")
|
34
|
+
end
|
35
|
+
|
36
|
+
if positive == false && decimal_value > 0
|
37
|
+
return error_i18n("not_positive")
|
38
|
+
end
|
39
|
+
|
40
|
+
if positive == true && decimal_value <= 0
|
41
|
+
return error_i18n("only_positive")
|
42
|
+
end
|
43
|
+
|
26
44
|
[:ok, decimal_value]
|
27
45
|
rescue ArgumentError
|
28
46
|
return error_i18n("big_decimal")
|
@@ -42,17 +60,20 @@ class Explicit::Type::BigDecimal < Explicit::Type
|
|
42
60
|
end
|
43
61
|
end
|
44
62
|
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
63
|
+
def json_schema(flavour)
|
64
|
+
{
|
65
|
+
type: "string",
|
66
|
+
pattern: /^\d*\.?\d*$/.inspect[1..-2],
|
67
|
+
format: "decimal number",
|
68
|
+
description_topics: [
|
69
|
+
swagger_i18n("big_decimal_format"),
|
70
|
+
min&.then { swagger_i18n("big_decimal_min", min: _1) },
|
71
|
+
max&.then { swagger_i18n("big_decimal_max", max: _1) },
|
72
|
+
positive == false ? swagger_i18n("number_not_positive") : nil,
|
73
|
+
positive == true ? swagger_i18n("number_only_positive") : nil,
|
74
|
+
negative == false ? swagger_i18n("number_not_negative") : nil,
|
75
|
+
negative == true ? swagger_i18n("number_only_negative") : nil
|
76
|
+
].compact
|
77
|
+
}
|
57
78
|
end
|
58
79
|
end
|
data/lib/explicit/type/date.rb
CHANGED
@@ -51,16 +51,14 @@ class Explicit::Type::Date < Explicit::Type
|
|
51
51
|
end
|
52
52
|
end
|
53
53
|
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
})
|
64
|
-
end
|
54
|
+
def json_schema(flavour)
|
55
|
+
{
|
56
|
+
type: "string",
|
57
|
+
pattern: /\d{4}-\d{2}-\d{2}/.inspect[1..-2],
|
58
|
+
format: "date",
|
59
|
+
description_topics: [
|
60
|
+
swagger_i18n("date_format")
|
61
|
+
]
|
62
|
+
}
|
65
63
|
end
|
66
64
|
end
|
@@ -78,18 +78,16 @@ class Explicit::Type::DateRange < Explicit::Type
|
|
78
78
|
end
|
79
79
|
end
|
80
80
|
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
})
|
93
|
-
end
|
81
|
+
def json_schema(flavour)
|
82
|
+
{
|
83
|
+
type: "string",
|
84
|
+
pattern: FORMAT.inspect[1..-2],
|
85
|
+
format: "date range",
|
86
|
+
description_topics: [
|
87
|
+
swagger_i18n("date_range"),
|
88
|
+
min_range&.then { swagger_i18n("date_range_min_range", min_range: _1.inspect) },
|
89
|
+
max_range&.then { swagger_i18n("date_range_max_range", max_range: _1.inspect) },
|
90
|
+
]
|
91
|
+
}
|
94
92
|
end
|
95
93
|
end
|
@@ -52,15 +52,13 @@ class Explicit::Type::DateTimeISO8601 < Explicit::Type
|
|
52
52
|
end
|
53
53
|
end
|
54
54
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
})
|
64
|
-
end
|
55
|
+
def json_schema(flavour)
|
56
|
+
{
|
57
|
+
type: "string",
|
58
|
+
format: "date-time",
|
59
|
+
description_topics: [
|
60
|
+
swagger_i18n("date_time_iso8601")
|
61
|
+
]
|
62
|
+
}
|
65
63
|
end
|
66
|
-
end
|
64
|
+
end
|
@@ -92,17 +92,15 @@ class Explicit::Type::DateTimeISO8601Range < Explicit::Type
|
|
92
92
|
end
|
93
93
|
end
|
94
94
|
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
})
|
106
|
-
end
|
95
|
+
def json_schema(flavour)
|
96
|
+
{
|
97
|
+
type: "string",
|
98
|
+
format: "date time range",
|
99
|
+
description_topics: [
|
100
|
+
swagger_i18n("date_time_iso8601_range"),
|
101
|
+
min_range&.then { swagger_i18n("date_time_iso8601_range_min_range", min_range: _1.inspect) },
|
102
|
+
max_range&.then { swagger_i18n("date_time_iso8601_range_max_range", max_range: _1.inspect) },
|
103
|
+
]
|
104
|
+
}
|
107
105
|
end
|
108
106
|
end
|
@@ -54,16 +54,14 @@ class Explicit::Type::DateTimeUnixEpoch < Explicit::Type
|
|
54
54
|
end
|
55
55
|
end
|
56
56
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
})
|
67
|
-
end
|
57
|
+
def json_schema(flavour)
|
58
|
+
{
|
59
|
+
type: "integer",
|
60
|
+
minimum: 1,
|
61
|
+
format: "POSIX time",
|
62
|
+
description_topics: [
|
63
|
+
swagger_i18n("date_time_unix_epoch")
|
64
|
+
]
|
65
|
+
}
|
68
66
|
end
|
69
67
|
end
|
data/lib/explicit/type/enum.rb
CHANGED
@@ -29,12 +29,10 @@ class Explicit::Type::Enum < Explicit::Type
|
|
29
29
|
end
|
30
30
|
end
|
31
31
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
})
|
38
|
-
end
|
32
|
+
def json_schema(flavour)
|
33
|
+
{
|
34
|
+
type: "string",
|
35
|
+
enum: allowed_values
|
36
|
+
}
|
39
37
|
end
|
40
38
|
end
|
data/lib/explicit/type/file.rb
CHANGED
@@ -45,16 +45,14 @@ class Explicit::Type::File < Explicit::Type
|
|
45
45
|
end
|
46
46
|
end
|
47
47
|
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
})
|
58
|
-
end
|
48
|
+
def json_schema(flavour)
|
49
|
+
{
|
50
|
+
type: "string",
|
51
|
+
format: "binary",
|
52
|
+
description_topics: [
|
53
|
+
max_size&.then { swagger_i18n("file_max_size", max_size: number_to_human_size(_1)) },
|
54
|
+
content_types.any? ? swagger_i18n("file_content_types", content_types: content_types.join(', ')) : nil
|
55
|
+
]
|
56
|
+
}
|
59
57
|
end
|
60
58
|
end
|
data/lib/explicit/type/float.rb
CHANGED
@@ -69,19 +69,17 @@ class Explicit::Type::Float < Explicit::Type
|
|
69
69
|
end
|
70
70
|
end
|
71
71
|
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
}.compact_blank)
|
85
|
-
end
|
72
|
+
def json_schema(flavour)
|
73
|
+
{
|
74
|
+
type: "number",
|
75
|
+
minimum: min,
|
76
|
+
maximum: max,
|
77
|
+
description_topics: [
|
78
|
+
positive == false ? swagger_i18n("number_not_positive") : nil,
|
79
|
+
positive == true ? swagger_i18n("number_only_positive") : nil,
|
80
|
+
negative == false ? swagger_i18n("number_not_negative") : nil,
|
81
|
+
negative == true ? swagger_i18n("number_only_negative") : nil
|
82
|
+
].compact_blank
|
83
|
+
}.compact_blank
|
86
84
|
end
|
87
85
|
end
|
data/lib/explicit/type/hash.rb
CHANGED
@@ -46,15 +46,13 @@ class Explicit::Type::Hash < Explicit::Type
|
|
46
46
|
end
|
47
47
|
end
|
48
48
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
})
|
58
|
-
end
|
49
|
+
def json_schema(flavour)
|
50
|
+
{
|
51
|
+
type: "object",
|
52
|
+
additionalProperties: value_type.mcp_schema,
|
53
|
+
description_topics: [
|
54
|
+
empty == false ? swagger_i18n("hash_not_empty") : nil
|
55
|
+
]
|
56
|
+
}
|
59
57
|
end
|
60
58
|
end
|
@@ -69,19 +69,17 @@ class Explicit::Type::Integer < Explicit::Type
|
|
69
69
|
end
|
70
70
|
end
|
71
71
|
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
}.compact_blank)
|
85
|
-
end
|
72
|
+
def json_schema(flavour)
|
73
|
+
{
|
74
|
+
type: "integer",
|
75
|
+
minimum: min,
|
76
|
+
maximum: max,
|
77
|
+
description_topics: [
|
78
|
+
positive == false ? swagger_i18n("number_not_positive") : nil,
|
79
|
+
positive == true ? swagger_i18n("number_only_positive") : nil,
|
80
|
+
negative == false ? swagger_i18n("number_not_negative") : nil,
|
81
|
+
negative == true ? swagger_i18n("number_only_negative") : nil
|
82
|
+
].compact_blank
|
83
|
+
}.compact_blank
|
86
84
|
end
|
87
85
|
end
|
@@ -33,12 +33,10 @@ class Explicit::Type::Literal < Explicit::Type
|
|
33
33
|
end
|
34
34
|
end
|
35
35
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
})
|
42
|
-
end
|
36
|
+
def json_schema(flavour)
|
37
|
+
{
|
38
|
+
type: @value.is_a?(::String) ? "string" : "integer",
|
39
|
+
enum: [@value]
|
40
|
+
}
|
43
41
|
end
|
44
42
|
end
|
data/lib/explicit/type/one_of.rb
CHANGED
@@ -101,11 +101,9 @@ class Explicit::Type::OneOf < Explicit::Type
|
|
101
101
|
end
|
102
102
|
end
|
103
103
|
|
104
|
-
|
105
|
-
|
106
|
-
return subtypes.first.swagger_schema if subtypes.one?
|
104
|
+
def json_schema(flavour)
|
105
|
+
raise ::NotImplementedError if flavour == :mcp
|
107
106
|
|
108
|
-
|
109
|
-
end
|
107
|
+
{ oneOf: subtypes.map(&:swagger_schema) }
|
110
108
|
end
|
111
109
|
end
|
data/lib/explicit/type/record.rb
CHANGED
@@ -73,33 +73,20 @@ class Explicit::Type::Record < Explicit::Type
|
|
73
73
|
end
|
74
74
|
end
|
75
75
|
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
{
|
80
|
-
name:,
|
81
|
-
in: type.param_location_path? ? "path" : "body",
|
82
|
-
description: type.description,
|
83
|
-
required: !type.nilable,
|
84
|
-
schema: type.swagger_schema
|
85
|
-
}
|
86
|
-
end
|
76
|
+
def json_schema(flavour)
|
77
|
+
properties = attributes.to_h do |name, type|
|
78
|
+
[ name, flavour == :mcp ? type.mcp_schema : type.swagger_schema ]
|
87
79
|
end
|
88
80
|
|
89
|
-
|
90
|
-
|
91
|
-
[ name, type.swagger_schema ]
|
92
|
-
end
|
93
|
-
|
94
|
-
required = attributes.filter_map do |name, type|
|
95
|
-
type.required? ? name.to_s : nil
|
96
|
-
end
|
97
|
-
|
98
|
-
merge_base_swagger_schema({
|
99
|
-
type: "object",
|
100
|
-
properties:,
|
101
|
-
required:
|
102
|
-
}.compact_blank)
|
81
|
+
required = attributes.filter_map do |name, type|
|
82
|
+
type.required? ? name.to_s : nil
|
103
83
|
end
|
84
|
+
|
85
|
+
{
|
86
|
+
type: "object",
|
87
|
+
properties:,
|
88
|
+
required:,
|
89
|
+
additionalProperties: false
|
90
|
+
}.compact
|
104
91
|
end
|
105
92
|
end
|
data/lib/explicit/type/string.rb
CHANGED
@@ -51,18 +51,16 @@ class Explicit::Type::String < Explicit::Type
|
|
51
51
|
end
|
52
52
|
end
|
53
53
|
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
}.compact_blank)
|
66
|
-
end
|
54
|
+
def json_schema(flavour)
|
55
|
+
{
|
56
|
+
type: "string",
|
57
|
+
pattern: format&.inspect&.then { _1[1..-2] },
|
58
|
+
minLength: min_length || (empty == false ? 1 : nil),
|
59
|
+
maxLength: max_length,
|
60
|
+
description_topics: [
|
61
|
+
empty == false ? swagger_i18n("string_not_empty") : nil,
|
62
|
+
downcase == true ? swagger_i18n("string_downcase") : nil
|
63
|
+
].compact_blank
|
64
|
+
}.compact_blank
|
67
65
|
end
|
68
66
|
end
|
data/lib/explicit/type.rb
CHANGED
@@ -130,7 +130,7 @@ class Explicit::Type
|
|
130
130
|
end
|
131
131
|
|
132
132
|
def required?
|
133
|
-
!nilable
|
133
|
+
!nilable && default.blank?
|
134
134
|
end
|
135
135
|
|
136
136
|
def error_i18n(name, context = {})
|
@@ -156,7 +156,15 @@ class Explicit::Type
|
|
156
156
|
end
|
157
157
|
end
|
158
158
|
|
159
|
-
def
|
159
|
+
def swagger_schema
|
160
|
+
merge_base_json_schema(json_schema(:swagger))
|
161
|
+
end
|
162
|
+
|
163
|
+
def mcp_schema
|
164
|
+
merge_base_json_schema(json_schema(:mcp))
|
165
|
+
end
|
166
|
+
|
167
|
+
def merge_base_json_schema(attributes)
|
160
168
|
topics = attributes.delete(:description_topics)&.compact_blank || []
|
161
169
|
|
162
170
|
formatted_description =
|
data/lib/explicit/version.rb
CHANGED
data/lib/explicit.rb
CHANGED
@@ -4,6 +4,13 @@ require "explicit/configuration"
|
|
4
4
|
require "explicit/engine"
|
5
5
|
require "explicit/version"
|
6
6
|
|
7
|
+
require "explicit/mcp_server"
|
8
|
+
require "explicit/mcp_server/builder"
|
9
|
+
require "explicit/mcp_server/request"
|
10
|
+
require "explicit/mcp_server/router"
|
11
|
+
require "explicit/mcp_server/response"
|
12
|
+
require "explicit/mcp_server/tool"
|
13
|
+
|
7
14
|
require "explicit/documentation"
|
8
15
|
require "explicit/documentation/builder"
|
9
16
|
require "explicit/documentation/markdown"
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: explicit
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.16
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Luiz Vasconcellos
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
|
-
date:
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: rails
|
@@ -85,6 +85,12 @@ files:
|
|
85
85
|
- lib/explicit/documentation/page/request.rb
|
86
86
|
- lib/explicit/documentation/section.rb
|
87
87
|
- lib/explicit/engine.rb
|
88
|
+
- lib/explicit/mcp_server.rb
|
89
|
+
- lib/explicit/mcp_server/builder.rb
|
90
|
+
- lib/explicit/mcp_server/request.rb
|
91
|
+
- lib/explicit/mcp_server/response.rb
|
92
|
+
- lib/explicit/mcp_server/router.rb
|
93
|
+
- lib/explicit/mcp_server/tool.rb
|
88
94
|
- lib/explicit/request.rb
|
89
95
|
- lib/explicit/request/example.rb
|
90
96
|
- lib/explicit/request/invalid_params_error.rb
|
@@ -141,7 +147,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
141
147
|
- !ruby/object:Gem::Version
|
142
148
|
version: '0'
|
143
149
|
requirements: []
|
144
|
-
rubygems_version: 3.6.
|
150
|
+
rubygems_version: 3.6.7
|
145
151
|
specification_version: 4
|
146
152
|
summary: Explicit is a validation and documentation library for REST APIs
|
147
153
|
test_files: []
|