explicit 0.2.14 → 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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +144 -9
  3. data/app/views/explicit/documentation/_page.html.erb +1 -1
  4. data/app/views/explicit/documentation/type/_big_decimal.html.erb +20 -0
  5. data/config/locales/en.yml +1 -0
  6. data/lib/explicit/documentation/builder.rb +1 -1
  7. data/lib/explicit/documentation/output/swagger.rb +21 -2
  8. data/lib/explicit/documentation/output/webpage.rb +4 -3
  9. data/lib/explicit/documentation.rb +1 -1
  10. data/lib/explicit/mcp_server/builder.rb +50 -0
  11. data/lib/explicit/mcp_server/request.rb +42 -0
  12. data/lib/explicit/mcp_server/response.rb +29 -0
  13. data/lib/explicit/mcp_server/router.rb +84 -0
  14. data/lib/explicit/mcp_server/tool.rb +20 -0
  15. data/lib/explicit/mcp_server.rb +39 -0
  16. data/lib/explicit/request.rb +35 -1
  17. data/lib/explicit/type/agreement.rb +7 -9
  18. data/lib/explicit/type/any.rb +4 -4
  19. data/lib/explicit/type/array.rb +6 -8
  20. data/lib/explicit/type/big_decimal.rb +36 -15
  21. data/lib/explicit/type/boolean.rb +4 -6
  22. data/lib/explicit/type/date.rb +9 -11
  23. data/lib/explicit/type/date_range.rb +11 -13
  24. data/lib/explicit/type/date_time_iso8601.rb +9 -11
  25. data/lib/explicit/type/date_time_iso8601_range.rb +10 -12
  26. data/lib/explicit/type/date_time_unix_epoch.rb +9 -11
  27. data/lib/explicit/type/enum.rb +5 -7
  28. data/lib/explicit/type/file.rb +9 -11
  29. data/lib/explicit/type/float.rb +12 -14
  30. data/lib/explicit/type/hash.rb +8 -10
  31. data/lib/explicit/type/integer.rb +12 -14
  32. data/lib/explicit/type/literal.rb +5 -7
  33. data/lib/explicit/type/one_of.rb +3 -5
  34. data/lib/explicit/type/record.rb +12 -25
  35. data/lib/explicit/type/string.rb +11 -13
  36. data/lib/explicit/type.rb +10 -2
  37. data/lib/explicit/version.rb +1 -1
  38. data/lib/explicit.rb +7 -0
  39. metadata +9 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d46564124ef78f3d7caaa7985d8ccf95a8eb43ff0dcd2853a36836e29076e8b
4
- data.tar.gz: f13116b827d4574cae5a9476b359cc64ca2ec6e0040db678793e21cce552547e
3
+ metadata.gz: 6ea020d82544c742432038439630cad304e496fb33335e44de226db513feacbe
4
+ data.tar.gz: f733ee198a502d8962e5e726527153f28e924e275ddbe7f965e7b8dc89ee74f9
5
5
  SHA512:
6
- metadata.gz: acb499d03d7293a55b419bc8e5df6252520f93972347874868175720eb3654b4ddd75a87b0896a50092e8c325ac98dfcaf25ec36b3115806eba8b15fa7e8aa63
7
- data.tar.gz: cdc1ba59de204f9d9f4d6f26afc7591dd98e3f36d9aa96e571473543d6d4603fb15ecc137882006ad569d911705e0442d2a13ffc19f729307c86c5fb1e8e003a
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
  ```
@@ -56,7 +56,7 @@
56
56
  Version <%= version %>
57
57
  </div>
58
58
  <div class="p-1 w-1/2">
59
- <%= link_to "https://petstore.swagger.io/?url=#{url_helpers.explicit_documentation_swagger_url(host: request.host)}", target: "_blank", class: "flex items-center justify-center gap-1 text-neutral-900" do %>
59
+ <%= link_to "https://petstore.swagger.io/?url=#{url_helpers.explicit_documentation_swagger_url(host:)}", target: "_blank", class: "flex items-center justify-center gap-1 text-neutral-900" do %>
60
60
  <span>Swagger</span>
61
61
 
62
62
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="size-4">
@@ -1,4 +1,24 @@
1
+ <p>
2
+ A string-encoded decimal number. For example: <code>"123.45"</code>.
3
+ </p>
4
+
1
5
  <%= type_constraints do %>
2
6
  <%= type_constraint "min:", type.min if type.min.present? %>
3
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 %>
4
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: {
@@ -43,10 +43,16 @@ module Explicit::Documentation::Output
43
43
  }
44
44
  end
45
45
 
46
- def call(request)
46
+ def call(env)
47
+ return respond_cors_preflight_request if env["REQUEST_METHOD"] == "OPTIONS"
48
+
47
49
  @swagger_document ||= swagger_document
48
50
 
49
- [ 200, { "Content-Type" => "application/json" }, [ @swagger_document.to_json ] ]
51
+ headers = cors_access_control_headers.merge({
52
+ "Content-Type" => "application/json"
53
+ })
54
+
55
+ [ 200, headers, [ @swagger_document.to_json ] ]
50
56
  end
51
57
 
52
58
  def inspect
@@ -54,6 +60,19 @@ module Explicit::Documentation::Output
54
60
  end
55
61
 
56
62
  private
63
+ def respond_cors_preflight_request
64
+ [ 200, cors_access_control_headers, [] ]
65
+ end
66
+
67
+ def cors_access_control_headers
68
+ {
69
+ "Access-Control-Allow-Origin" => "*",
70
+ "Access-Control-Allow-Methods" => "GET, OPTIONS",
71
+ "Access-Control-Allow-Headers" => "Content-Type",
72
+ "Access-Control-Max-Age" => "86400"
73
+ }
74
+ end
75
+
57
76
  def get_base_url
58
77
  base_urls = builder.requests.map(&:get_base_url).uniq
59
78
  base_paths = builder.requests.map(&:get_base_path).uniq
@@ -8,8 +8,8 @@ module Explicit::Documentation::Output
8
8
  @builder = builder
9
9
  end
10
10
 
11
- def call(request)
12
- @html ||= render_documentation_page
11
+ def call(env)
12
+ @html ||= render_documentation_page(host: env["HTTP_HOST"])
13
13
 
14
14
  [200, {}, [@html]]
15
15
  end
@@ -21,10 +21,11 @@ module Explicit::Documentation::Output
21
21
  private
22
22
  Eval = ->(value) { value.respond_to?(:call) ? value.call : value }
23
23
 
24
- def render_documentation_page
24
+ def render_documentation_page(host:)
25
25
  Explicit::ApplicationController.render(
26
26
  partial: "explicit/documentation/page",
27
27
  locals: {
28
+ host:,
28
29
  url_helpers: @builder.rails_engine.routes.url_helpers,
29
30
  page_title: Eval[builder.get_page_title],
30
31
  company_logo_url: Eval[builder.get_company_logo_url],
@@ -17,7 +17,7 @@ module Explicit::Documentation
17
17
 
18
18
  engine.routes.draw do
19
19
  get "/", to: builder.webpage, as: :explicit_documentation_webpage
20
- get "/swagger", to: builder.swagger, as: :explicit_documentation_swagger
20
+ match "/swagger", to: builder.swagger, as: :explicit_documentation_swagger, via: [:get, :options]
21
21
  end
22
22
 
23
23
  engine
@@ -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)