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.
- checksums.yaml +4 -4
- data/README.md +144 -9
- data/app/views/explicit/documentation/_page.html.erb +1 -1
- data/app/views/explicit/documentation/type/_big_decimal.html.erb +20 -0
- data/config/locales/en.yml +1 -0
- data/lib/explicit/documentation/builder.rb +1 -1
- data/lib/explicit/documentation/output/swagger.rb +21 -2
- data/lib/explicit/documentation/output/webpage.rb +4 -3
- data/lib/explicit/documentation.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 +36 -15
- 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 -6
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
|
```
|
@@ -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:
|
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 %>
|
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"'
|
@@ -43,10 +43,16 @@ module Explicit::Documentation::Output
|
|
43
43
|
}
|
44
44
|
end
|
45
45
|
|
46
|
-
def call(
|
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
|
-
|
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(
|
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
|
-
|
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
|
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)
|