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: []
         |