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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de123083ab64bba09159b8980d3ceb0d838ded515e163d4360129fc7accd4a53
4
- data.tar.gz: d221e834b71e4d45fec25800ec995bd1a5c25a14a973301047a3b9932033773b
3
+ metadata.gz: 6ea020d82544c742432038439630cad304e496fb33335e44de226db513feacbe
4
+ data.tar.gz: f733ee198a502d8962e5e726527153f28e924e275ddbe7f965e7b8dc89ee74f9
5
5
  SHA512:
6
- metadata.gz: 54a295c83d2df60e452d7088f04d83fb419820190fc6445b3f07593adb01344689d3a95758e1b98a37312cf24d54a6a8c5dbed834c4137f11f51cee2db5096e1
7
- data.tar.gz: d731ecb3e2e3d305857fa522d6eb9b9889ee8223eff2201f85c8eb62ccd784e0cedabe5bfb1b6c2a56db3b21d64996cfbc001d8140cf55da8d0ac647e9fc4626
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. Types
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
- 8. [Configuration](#configuration)
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
- - `add_example(params:, headers:, response:)` - Adds an example to the
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 `add_example(params:, headers:, response:)`
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 `add_example(params:, headers:, response:)` after declaring
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
- add_example(
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
- add_example MyApp::Examples::REQUEST_1
360
- add_example MyApp::Examples::REQUEST_2
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 %>
@@ -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"'
@@ -67,7 +67,7 @@ module Explicit::Documentation
67
67
 
68
68
  requests.each do |request|
69
69
  examples[request.gid]&.each do |example|
70
- request.add_example(
70
+ request.example(
71
71
  params: example["params"].with_indifferent_access,
72
72
  headers: example["headers"],
73
73
  response: {
@@ -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
@@ -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 add_example(params:, response:, headers: {})
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
- concerning :Swagger do
30
- def swagger_schema
31
- merge_base_swagger_schema({
32
- type: "boolean",
33
- description_topics: [
34
- swagger_i18n("agreement")
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
@@ -19,9 +19,9 @@ class Explicit::Type::Any < Explicit::Type
19
19
  end
20
20
  end
21
21
 
22
- concerning :Swagger do
23
- def swagger_schema
24
- merge_base_swagger_schema({})
25
- end
22
+ def json_schema(flavour)
23
+ return {} if flavour == :swagger
24
+
25
+ { type: "string" }
26
26
  end
27
27
  end
@@ -43,13 +43,11 @@ class Explicit::Type::Array < Explicit::Type
43
43
  end
44
44
  end
45
45
 
46
- concerning :Swagger do
47
- def swagger_schema
48
- merge_base_swagger_schema({
49
- type: "array",
50
- items: item_type.swagger_schema,
51
- minItems: empty ? 0 : 1
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
- concerning :Swagger do
46
- def swagger_schema
47
- merge_base_swagger_schema({
48
- type: "string",
49
- pattern: /^\d*\.?\d*$/.inspect[1..-2],
50
- format: "decimal number",
51
- description_topics: [
52
- min&.then { swagger_i18n("big_decimal_min", min: _1) },
53
- max&.then { swagger_i18n("big_decimal_max", max: _1) }
54
- ]
55
- })
56
- end
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
@@ -36,11 +36,9 @@ class Explicit::Type::Boolean < Explicit::Type
36
36
  end
37
37
  end
38
38
 
39
- concerning :Swagger do
40
- def swagger_schema
41
- merge_base_swagger_schema({
42
- type: "boolean"
43
- })
44
- end
39
+ def json_schema(flavour)
40
+ {
41
+ type: "boolean"
42
+ }
45
43
  end
46
44
  end
@@ -51,16 +51,14 @@ class Explicit::Type::Date < Explicit::Type
51
51
  end
52
52
  end
53
53
 
54
- concerning :Swagger do
55
- def swagger_schema
56
- merge_base_swagger_schema({
57
- type: "string",
58
- pattern: /\d{4}-\d{2}-\d{2}/.inspect[1..-2],
59
- format: "date",
60
- description_topics: [
61
- swagger_i18n("date_format")
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
- concerning :Swagger do
82
- def swagger_schema
83
- merge_base_swagger_schema({
84
- type: "string",
85
- pattern: FORMAT.inspect[1..-2],
86
- format: "date range",
87
- description_topics: [
88
- swagger_i18n("date_range"),
89
- min_range&.then { swagger_i18n("date_range_min_range", min_range: _1.inspect) },
90
- max_range&.then { swagger_i18n("date_range_max_range", max_range: _1.inspect) },
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
- concerning :Swagger do
56
- def swagger_schema
57
- merge_base_swagger_schema({
58
- type: "string",
59
- format: "date-time",
60
- description_topics: [
61
- swagger_i18n("date_time_iso8601")
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
- concerning :Swagger do
96
- def swagger_schema
97
- merge_base_swagger_schema({
98
- type: "string",
99
- format: "date time range",
100
- description_topics: [
101
- swagger_i18n("date_time_iso8601_range"),
102
- min_range&.then { swagger_i18n("date_time_iso8601_range_min_range", min_range: _1.inspect) },
103
- max_range&.then { swagger_i18n("date_time_iso8601_range_max_range", max_range: _1.inspect) },
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
- concerning :Swagger do
58
- def swagger_schema
59
- merge_base_swagger_schema({
60
- type: "integer",
61
- minimum: 1,
62
- format: "POSIX time",
63
- description_topics: [
64
- swagger_i18n("date_time_unix_epoch")
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
@@ -29,12 +29,10 @@ class Explicit::Type::Enum < Explicit::Type
29
29
  end
30
30
  end
31
31
 
32
- concerning :Swagger do
33
- def swagger_schema
34
- merge_base_swagger_schema({
35
- type: "string",
36
- enum: allowed_values
37
- })
38
- end
32
+ def json_schema(flavour)
33
+ {
34
+ type: "string",
35
+ enum: allowed_values
36
+ }
39
37
  end
40
38
  end
@@ -45,16 +45,14 @@ class Explicit::Type::File < Explicit::Type
45
45
  end
46
46
  end
47
47
 
48
- concerning :Swagger do
49
- def swagger_schema
50
- merge_base_swagger_schema({
51
- type: "string",
52
- format: "binary",
53
- description_topics: [
54
- max_size&.then { swagger_i18n("file_max_size", max_size: number_to_human_size(_1)) },
55
- content_types.any? ? swagger_i18n("file_content_types", content_types: content_types.join(', ')) : nil
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
@@ -69,19 +69,17 @@ class Explicit::Type::Float < Explicit::Type
69
69
  end
70
70
  end
71
71
 
72
- concerning :Swagger do
73
- def swagger_schema
74
- merge_base_swagger_schema({
75
- type: "number",
76
- minimum: min,
77
- maximum: max,
78
- description_topics: [
79
- positive == false ? swagger_i18n("number_not_positive") : nil,
80
- positive == true ? swagger_i18n("number_only_positive") : nil,
81
- negative == false ? swagger_i18n("number_not_negative") : nil,
82
- negative == true ? swagger_i18n("number_only_negative") : nil
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
@@ -46,15 +46,13 @@ class Explicit::Type::Hash < Explicit::Type
46
46
  end
47
47
  end
48
48
 
49
- concerning :Swagger do
50
- def swagger_schema
51
- merge_base_swagger_schema({
52
- type: "object",
53
- additionalProperties: value_type.swagger_schema,
54
- description_topics: [
55
- empty == false ? swagger_i18n("hash_not_empty") : nil
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
- concerning :Swagger do
73
- def swagger_schema
74
- merge_base_swagger_schema({
75
- type: "integer",
76
- minimum: min,
77
- maximum: max,
78
- description_topics: [
79
- positive == false ? swagger_i18n("number_not_positive") : nil,
80
- positive == true ? swagger_i18n("number_only_positive") : nil,
81
- negative == false ? swagger_i18n("number_not_negative") : nil,
82
- negative == true ? swagger_i18n("number_only_negative") : nil
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
- concerning :Swagger do
37
- def swagger_schema
38
- merge_base_swagger_schema({
39
- type: @value.is_a?(::String) ? "string" : "integer",
40
- enum: [@value]
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
@@ -101,11 +101,9 @@ class Explicit::Type::OneOf < Explicit::Type
101
101
  end
102
102
  end
103
103
 
104
- concerning :Swagger do
105
- def swagger_schema
106
- return subtypes.first.swagger_schema if subtypes.one?
104
+ def json_schema(flavour)
105
+ raise ::NotImplementedError if flavour == :mcp
107
106
 
108
- { oneOf: subtypes.map(&:swagger_schema) }
109
- end
107
+ { oneOf: subtypes.map(&:swagger_schema) }
110
108
  end
111
109
  end
@@ -73,33 +73,20 @@ class Explicit::Type::Record < Explicit::Type
73
73
  end
74
74
  end
75
75
 
76
- concerning :Swagger do
77
- def swagger_parameters
78
- attributes.map do |name, type|
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
- def swagger_schema
90
- properties = attributes.to_h do |name, type|
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
@@ -51,18 +51,16 @@ class Explicit::Type::String < Explicit::Type
51
51
  end
52
52
  end
53
53
 
54
- concerning :Swagger do
55
- def swagger_schema
56
- merge_base_swagger_schema({
57
- type: "string",
58
- pattern: format&.inspect&.then { _1[1..-2] },
59
- minLength: min_length || (empty == false ? 1 : nil),
60
- maxLength: max_length,
61
- description_topics: [
62
- empty == false ? swagger_i18n("string_not_empty") : nil,
63
- downcase == true ? swagger_i18n("string_downcase") : nil
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 merge_base_swagger_schema(attributes)
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 =
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Explicit
4
- VERSION = "0.2.15"
4
+ VERSION = "0.2.16"
5
5
  end
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.15
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: 2025-04-23 00:00:00.000000000 Z
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.2
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: []