mcp_toolkit 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +43 -0
  3. data/.rubocop.yml +98 -0
  4. data/CHANGELOG.md +123 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +301 -0
  7. data/Rakefile +21 -0
  8. data/app/controllers/mcp_toolkit/server_controller.rb +19 -0
  9. data/config/routes.rb +18 -0
  10. data/lib/mcp_toolkit/auth/authenticator.rb +99 -0
  11. data/lib/mcp_toolkit/auth/authority.rb +75 -0
  12. data/lib/mcp_toolkit/auth/authority_server_client.rb +50 -0
  13. data/lib/mcp_toolkit/auth/introspection.rb +162 -0
  14. data/lib/mcp_toolkit/configuration.rb +209 -0
  15. data/lib/mcp_toolkit/engine.rb +21 -0
  16. data/lib/mcp_toolkit/errors/base.rb +10 -0
  17. data/lib/mcp_toolkit/errors/configuration_error.rb +5 -0
  18. data/lib/mcp_toolkit/errors/invalid_params.rb +5 -0
  19. data/lib/mcp_toolkit/errors/unauthorized.rb +5 -0
  20. data/lib/mcp_toolkit/field_selection.rb +93 -0
  21. data/lib/mcp_toolkit/filtering.rb +152 -0
  22. data/lib/mcp_toolkit/get_executor.rb +32 -0
  23. data/lib/mcp_toolkit/list_executor.rb +137 -0
  24. data/lib/mcp_toolkit/registry.rb +67 -0
  25. data/lib/mcp_toolkit/resource.rb +129 -0
  26. data/lib/mcp_toolkit/resource_schema.rb +163 -0
  27. data/lib/mcp_toolkit/serialization.rb +62 -0
  28. data/lib/mcp_toolkit/serializer/base.rb +285 -0
  29. data/lib/mcp_toolkit/server.rb +44 -0
  30. data/lib/mcp_toolkit/session.rb +46 -0
  31. data/lib/mcp_toolkit/sql_sanitizer.rb +14 -0
  32. data/lib/mcp_toolkit/token_kinds.rb +14 -0
  33. data/lib/mcp_toolkit/tools/base.rb +100 -0
  34. data/lib/mcp_toolkit/tools/get.rb +57 -0
  35. data/lib/mcp_toolkit/tools/list.rb +83 -0
  36. data/lib/mcp_toolkit/tools/resource_schema.rb +40 -0
  37. data/lib/mcp_toolkit/tools/resources.rb +27 -0
  38. data/lib/mcp_toolkit/transport/controller_methods.rb +226 -0
  39. data/lib/mcp_toolkit/unknown_resource_message.rb +75 -0
  40. data/lib/mcp_toolkit/version.rb +5 -0
  41. data/lib/mcp_toolkit.rb +118 -0
  42. data/sig/mcp_toolkit.rbs +4 -0
  43. metadata +147 -0
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base class for the generic MCP tools. Subclasses an official-SDK `MCP::Tool`,
4
+ # so `name`/`description`/`input_schema` and the `call` contract are the gem's.
5
+ # This base adds the shared concern every tool needs: authenticating +
6
+ # scope-resolving the caller (via McpToolkit::Auth::Authenticator) before
7
+ # running, and turning tool-level errors into `isError: true` MCP results
8
+ # (rather than letting them become JSON-RPC protocol errors).
9
+ #
10
+ # The bearer token, JSON-RPC `_meta`, and the account-id header are threaded in
11
+ # through `server_context` (set per-request by the controller). The active
12
+ # McpToolkit config is also threaded in as `server_context[:mcp_config]` so a
13
+ # process can, in principle, host more than one configured server; it falls back
14
+ # to `McpToolkit.config`.
15
+ class McpToolkit::Tools::Base < MCP::Tool
16
+ # Runs `block` with an authenticated, scoped context, serializing any
17
+ # McpToolkit::Errors into a clean text tool error.
18
+ #
19
+ # The resolved `scope_root` is yielded — it is the tools' serializer `scope`
20
+ # AND the root every query is scoped through.
21
+ #
22
+ # `account_id` is the superuser account selector arriving as a tool
23
+ # argument (the gem passes tool args as kwargs, not via server_context),
24
+ # threaded here so it joins `_meta` / the header in the resolution order.
25
+ #
26
+ # `required_scope` is the explicitly-declared scope a token must carry (the
27
+ # caller resolves it from the resource — see Registry#required_scope_for).
28
+ # Empty/nil => no scope check (authorized_for_scope? treats "" as a pass).
29
+ def self.with_account(server_context, account_id: nil, required_scope: nil)
30
+ config = config_from(server_context)
31
+ context = McpToolkit::Auth::Authenticator.call(
32
+ token: server_context[:bearer_token],
33
+ meta: meta_from(server_context),
34
+ arguments: { "account_id" => account_id }.compact,
35
+ header_account_id: server_context[:header_account_id],
36
+ config:
37
+ )
38
+
39
+ unless context.introspection.authorized_for_scope?(required_scope)
40
+ return error_response("Unauthorized: token lacks the #{required_scope.inspect} scope")
41
+ end
42
+
43
+ text_response(yield(context.scope_root))
44
+ rescue McpToolkit::Errors::Unauthorized => e
45
+ error_response("Unauthorized: #{e.message}")
46
+ rescue McpToolkit::Errors::InvalidParams => e
47
+ error_response("Invalid request: #{e.message}")
48
+ end
49
+
50
+ # Authenticates the token (valid + the explicitly-declared `required_scope`)
51
+ # WITHOUT requiring an account selection. Used by the schema-discovery tools,
52
+ # which reveal shape, not tenant data, so a superuser shouldn't have to pin an
53
+ # account just to discover what exists. Empty/nil `required_scope` => no scope
54
+ # check.
55
+ def self.with_authentication(server_context, required_scope: nil)
56
+ config = config_from(server_context)
57
+ introspection = McpToolkit::Auth::Introspection.call(server_context[:bearer_token], config:)
58
+ return error_response("Unauthorized: invalid or expired token") unless introspection.valid?
59
+
60
+ unless introspection.authorized_for_scope?(required_scope)
61
+ return error_response("Unauthorized: token lacks the #{required_scope.inspect} scope")
62
+ end
63
+
64
+ text_response(yield)
65
+ rescue McpToolkit::Errors::InvalidParams => e
66
+ error_response("Invalid request: #{e.message}")
67
+ end
68
+
69
+ def self.text_response(payload)
70
+ text = payload.is_a?(String) ? payload : JSON.generate(payload)
71
+ MCP::Tool::Response.new([{ type: "text", text: }])
72
+ end
73
+
74
+ def self.error_response(message)
75
+ MCP::Tool::Response.new([{ type: "text", text: message }], error: true)
76
+ end
77
+
78
+ # The gem nests the request `_meta` under server_context[:_meta].
79
+ def self.meta_from(server_context)
80
+ server_context[:_meta] || {}
81
+ end
82
+
83
+ def self.config_from(server_context)
84
+ server_context[:mcp_config] || McpToolkit.config
85
+ end
86
+
87
+ def self.lookup_resource(name, config)
88
+ config.registry.fetch(name)
89
+ rescue McpToolkit::Registry::UnknownResource => e
90
+ raise McpToolkit::Errors::InvalidParams, e.message
91
+ end
92
+
93
+ # Validates the `resource` argument is present and resolves its descriptor,
94
+ # raising InvalidParams (=> clean tool error) for a blank or unknown resource.
95
+ def self.resolve_descriptor(name, config)
96
+ raise McpToolkit::Errors::InvalidParams, "resource is required" if name.to_s.strip.empty?
97
+
98
+ lookup_resource(name, config)
99
+ end
100
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Fetches a single record by id from a registered resource, scoped to the
4
+ # resolved scope root.
5
+ class McpToolkit::Tools::Get < McpToolkit::Tools::Base
6
+ tool_name "get"
7
+ description <<~DESC.strip
8
+ Fetch a single record by id from a read-only resource. Pass the resource name as `resource`
9
+ and the record id as `id`. Use the `resources` tool to discover available resources.
10
+
11
+ For tokens that span multiple accounts (superuser), pass `account_id` to pin the active
12
+ account; account-scoped tokens may omit it. The response mirrors the resource's record
13
+ shape (attributes + a `links` block).
14
+
15
+ Pass `fields` to return a sparse fieldset — the attributes and/or relationships you name
16
+ (as an array or comma-separated string), omitting everything else. Include "id" if you need
17
+ it. Valid names come from the resource's `resource_schema`; unknown names are rejected.
18
+ DESC
19
+
20
+ input_schema(
21
+ properties: {
22
+ resource: {
23
+ type: "string",
24
+ description: "Resource name (use the `resources` tool to discover valid values)"
25
+ },
26
+ # The id type is left open so a string/UUID primary key works as well as an
27
+ # integer one; the record is looked up by the value as given, uncoerced.
28
+ id: { type: %w[string integer], description: "The record ID (integer or string/UUID)" },
29
+ account_id: {
30
+ type: "integer",
31
+ description: "Account to operate on. Required for superuser tokens; ignored otherwise."
32
+ },
33
+ fields: {
34
+ type: %w[array string],
35
+ items: { type: "string" },
36
+ description: "Sparse fieldset — names of attributes and/or relationships to include, as " \
37
+ "an array or a comma-separated string. Omit to return every field. Include " \
38
+ "\"id\" if you need it. Unknown names are rejected; see the resource's " \
39
+ "`resource_schema` for valid attribute and relationship names."
40
+ }
41
+ },
42
+ required: %w[resource id]
43
+ )
44
+
45
+ def self.call(server_context:, resource: nil, id: nil, account_id: nil, fields: nil, **_args)
46
+ config = config_from(server_context)
47
+ # Resolve the resource FIRST so its effective required scope is known before
48
+ # the scope check (and so an unknown resource is a clean tool error).
49
+ descriptor = resolve_descriptor(resource, config)
50
+ required_scope = config.registry.required_scope_for(descriptor)
51
+ with_account(server_context, account_id:, required_scope:) do |scope_root|
52
+ McpToolkit::GetExecutor.call(resource: descriptor, scope_root:, id:, fields:)
53
+ end
54
+ rescue McpToolkit::Errors::InvalidParams => e
55
+ error_response("Invalid request: #{e.message}")
56
+ end
57
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Fetches a paginated list of records from a registered resource, scoped to the
4
+ # resolved scope root.
5
+ class McpToolkit::Tools::List < McpToolkit::Tools::Base
6
+ tool_name "list"
7
+ description <<~DESC.strip
8
+ Fetch a paginated list of records from a read-only resource. Pass the resource name as
9
+ `resource`. Use the `resources` tool to discover resources and `resource_schema` to learn a
10
+ resource's shape.
11
+
12
+ Standard filters:
13
+ - ids: comma-separated list of IDs to fetch
14
+ - updated_since: ISO 8601 timestamp; only records updated after this time
15
+ - limit: page size (default 25, max 100)
16
+ - offset: pagination offset (default 0)
17
+
18
+ Per-attribute equality filters:
19
+ - filter: an object of { <key>: <value> } exact-match filters, applied ON TOP of the
20
+ account scope (they can only narrow, never widen). Each resource advertises its
21
+ available filter keys via `resource_schema` (the `filters` array). Unknown keys are
22
+ rejected.
23
+
24
+ Sparse fieldset:
25
+ - fields: names of the attributes and/or relationships to include in each record, as an
26
+ array or a comma-separated string. Omit to return every field. Narrowing the set shrinks
27
+ the response (and skips loading unselected relationships) — prefer it when you only need a
28
+ few fields. Include "id" if you need it. Valid names come from a resource's
29
+ `resource_schema` (its `attributes` and `relationships`); unknown names are rejected.
30
+
31
+ For tokens that span multiple accounts (superuser), pass `account_id` to pin the active
32
+ account; account-scoped tokens may omit it. The response shape is
33
+ { "<resource>": [...], "meta": { total_count, limit, offset } }.
34
+ DESC
35
+
36
+ input_schema(
37
+ properties: {
38
+ resource: {
39
+ type: "string",
40
+ description: "Resource name (use the `resources` tool to discover valid values)"
41
+ },
42
+ account_id: {
43
+ type: "integer",
44
+ description: "Account to operate on. Required for superuser tokens; ignored otherwise."
45
+ },
46
+ ids: { type: "string", description: "Comma-separated list of IDs to fetch" },
47
+ updated_since: {
48
+ type: "string",
49
+ description: "ISO 8601 timestamp; only records updated after this time"
50
+ },
51
+ filter: {
52
+ type: "object",
53
+ description: "Per-attribute exact-match equality filters, e.g. { \"booking_id\": 42 }. " \
54
+ "See a resource's `resource_schema` `filters` for the keys it accepts.",
55
+ additionalProperties: true
56
+ },
57
+ limit: { type: "integer", description: "Page size (default 25, max 100)" },
58
+ offset: { type: "integer", description: "Pagination offset (default 0)" },
59
+ fields: {
60
+ type: %w[array string],
61
+ items: { type: "string" },
62
+ description: "Sparse fieldset — names of attributes and/or relationships to include in " \
63
+ "each record, as an array or a comma-separated string. Omit to return every " \
64
+ "field. Include \"id\" if you need it. Unknown names are rejected; see a " \
65
+ "resource's `resource_schema` for valid attribute and relationship names."
66
+ }
67
+ },
68
+ required: ["resource"]
69
+ )
70
+
71
+ def self.call(server_context:, resource: nil, account_id: nil, **params)
72
+ config = config_from(server_context)
73
+ # Resolve the resource FIRST so its effective required scope is known before
74
+ # the scope check (and so an unknown resource is a clean tool error).
75
+ descriptor = resolve_descriptor(resource, config)
76
+ required_scope = config.registry.required_scope_for(descriptor)
77
+ with_account(server_context, account_id:, required_scope:) do |scope_root|
78
+ McpToolkit::ListExecutor.call(resource: descriptor, scope_root:, params:)
79
+ end
80
+ rescue McpToolkit::Errors::InvalidParams => e
81
+ error_response("Invalid request: #{e.message}")
82
+ end
83
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Discovery tool: the detailed schema (attributes + relationships) of one
4
+ # resource.
5
+ class McpToolkit::Tools::ResourceSchema < McpToolkit::Tools::Base
6
+ tool_name "resource_schema"
7
+ description <<~DESC.strip
8
+ Describe a single read-only resource in detail. Pass the resource name as `resource` (use
9
+ the `resources` tool to discover names). Returns:
10
+ - attributes: every field in the response, each with its `type` and a value `format` hint
11
+ - relationships: associated resources emitted in the record's `links`; each names the
12
+ `target_resource` it resolves to (callable via `list`/`get`)
13
+ - standard_filters: ids, updated_since, limit, offset (accepted by the `list` tool)
14
+ - filters: the per-attribute equality filter keys the `list` tool accepts
15
+ The `attributes` and `relationships` names are also the valid values for the `fields` sparse
16
+ fieldset argument on `get` / `list`. Call this before `list` to learn a resource's shape.
17
+ DESC
18
+
19
+ input_schema(
20
+ properties: {
21
+ resource: {
22
+ type: "string",
23
+ description: "Resource name (use the `resources` tool to discover valid values)"
24
+ }
25
+ },
26
+ required: ["resource"]
27
+ )
28
+
29
+ def self.call(server_context:, resource: nil, **_args)
30
+ config = config_from(server_context)
31
+ # Resolve the resource FIRST so its effective required scope gates discovery
32
+ # of THIS resource's shape (and an unknown resource is a clean tool error).
33
+ descriptor = resolve_descriptor(resource, config)
34
+ with_authentication(server_context, required_scope: config.registry.required_scope_for(descriptor)) do
35
+ McpToolkit::ResourceSchema.call(descriptor, registry: config.registry)
36
+ end
37
+ rescue McpToolkit::Errors::InvalidParams => e
38
+ error_response("Invalid request: #{e.message}")
39
+ end
40
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Discovery tool: lists every read-only resource exposed by this server.
4
+ class McpToolkit::Tools::Resources < McpToolkit::Tools::Base
5
+ tool_name "resources"
6
+ description <<~DESC.strip
7
+ List all read-only resources available via the `list` and `get` tools. Returns each
8
+ resource's name and a short description. Call this once at the start of a session to learn
9
+ what exists, then use `resource_schema` for a specific resource's attributes and
10
+ relationships.
11
+ DESC
12
+
13
+ input_schema(properties: {})
14
+
15
+ def self.call(server_context:, **_args)
16
+ config = config_from(server_context)
17
+ # Discovery requires the registry-level default scope (the satellite's
18
+ # app-wide scope); per-resource scopes are enforced by `get` / `list`.
19
+ with_authentication(server_context, required_scope: config.registry.default_required_permissions_scope) do
20
+ {
21
+ resources: config.registry.resources.map do |resource|
22
+ { name: resource.name, description: resource.description }
23
+ end
24
+ }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ # The MCP Streamable-HTTP transport, provided as an includable concern. An
6
+ # app's controller includes this to get the full transport with no per-app code:
7
+ #
8
+ # class Mcp::ServerController < ApplicationController
9
+ # include McpToolkit::Transport::ControllerMethods
10
+ # end
11
+ #
12
+ # and routes the four endpoints at its actions:
13
+ #
14
+ # post "mcp", to: "mcp/server#create"
15
+ # get "mcp", to: "mcp/server#stream"
16
+ # delete "mcp", to: "mcp/server#destroy"
17
+ # get "mcp/health", to: "mcp/server#health"
18
+ #
19
+ # Endpoints
20
+ # POST /mcp - JSON-RPC requests/responses
21
+ # GET /mcp - server-initiated SSE stream (none emitted; 405)
22
+ # DELETE /mcp - terminate the current session
23
+ # GET /mcp/health - unauthenticated health probe
24
+ #
25
+ # Authentication
26
+ # The bearer token is NOT verified at the transport boundary; it is forwarded
27
+ # into each tool, which authenticates it against the central app's
28
+ # introspection endpoint (McpToolkit::Auth::Authenticator). The transport only
29
+ # requires that *a* token be present (so unauthenticated calls are refused
30
+ # before any work). Tools resolve the active account from `_meta` /
31
+ # `account_id` argument / the account-id header.
32
+ #
33
+ # Session lifecycle
34
+ # - First `initialize` POST: create a session, return its id in the
35
+ # `Mcp-Session-Id` response header. The client echoes it on later requests.
36
+ # - Subsequent POSTs: validate the session id; missing/expired => 404.
37
+ # - DELETE /mcp ends the session.
38
+ #
39
+ # Notifications (requests without an `id`) get a 202 with no body.
40
+ #
41
+ # Overridable hooks
42
+ # - `mcp_config` -> the McpToolkit::Configuration to use (default: McpToolkit.config)
43
+ # - `mcp_extra_tools` -> Array of additional MCP::Tool subclasses (default: [])
44
+ #
45
+ # CSRF: the concern disables forgery protection (this is a token-authenticated
46
+ # JSON API). Inherit from ActionController::Base (not ::API) if your app's
47
+ # controller stack needs helper_method, as bsa-notifications does.
48
+ module McpToolkit::Transport::ControllerMethods
49
+ extend ActiveSupport::Concern
50
+
51
+ SESSION_HEADER = "Mcp-Session-Id"
52
+
53
+ included do
54
+ protect_from_forgery with: :null_session if respond_to?(:protect_from_forgery)
55
+
56
+ before_action :mcp_require_token!, except: [:health]
57
+ before_action :mcp_resolve_session!, only: [:create]
58
+ end
59
+
60
+ def create
61
+ request_body = mcp_parsed_body
62
+ server = McpToolkit::Server.build(
63
+ server_context: mcp_server_context,
64
+ config: mcp_config,
65
+ extra_tools: mcp_extra_tools
66
+ )
67
+ response_json = server.handle_json(JSON.generate(request_body))
68
+
69
+ # handle_json returns nil for notifications (no id) -> 202 Accepted, no body.
70
+ return head :accepted if response_json.nil?
71
+
72
+ mcp_render_response(response_json)
73
+ end
74
+
75
+ # GET /mcp - no server-initiated SSE stream is emitted; 405 per MCP spec.
76
+ def stream
77
+ head :method_not_allowed
78
+ end
79
+
80
+ # DELETE /mcp - terminate the current session.
81
+ def destroy
82
+ McpToolkit::Session.delete(request.headers[SESSION_HEADER], config: mcp_config)
83
+ head :no_content
84
+ end
85
+
86
+ # GET /mcp/health - unauthenticated probe.
87
+ def health
88
+ render json: {
89
+ status: "ok",
90
+ server: mcp_config.server_name,
91
+ version: mcp_config.server_version
92
+ }
93
+ end
94
+
95
+ private
96
+
97
+ # ---- overridable hooks ----------------------------------------------
98
+
99
+ def mcp_config
100
+ McpToolkit.config
101
+ end
102
+
103
+ def mcp_extra_tools
104
+ []
105
+ end
106
+
107
+ # Logger for transport-level diagnostics. Defaults to Rails.logger when running
108
+ # inside Rails, and to a $stdout logger otherwise (e.g. the gem's own unit suite)
109
+ # so a diagnostic is never silently dropped. Overridable so a host can inject its
110
+ # own; never returns nil.
111
+ def mcp_logger
112
+ return Rails.logger if defined?(Rails)
113
+
114
+ @mcp_logger ||= Logger.new($stdout)
115
+ end
116
+
117
+ # ---- per-request context --------------------------------------------
118
+
119
+ # Per-request context threaded to the tools. The gem merges the request's
120
+ # `_meta` into this hash (as `:_meta`) at tool-call time.
121
+ def mcp_server_context
122
+ {
123
+ bearer_token: mcp_extract_token,
124
+ header_account_id: request.headers[mcp_config.account_id_header].presence,
125
+ mcp_config: mcp_config
126
+ }
127
+ end
128
+
129
+ # Renders the JSON-RPC payload as application/json by default, or as a single
130
+ # SSE `message` frame when the client's Accept header asks for it. We never
131
+ # actually stream (one message + EOF) so a strict client interoperates either
132
+ # way.
133
+ def mcp_render_response(response_json)
134
+ if mcp_event_stream_requested?
135
+ response.headers["Cache-Control"] = "no-cache"
136
+ body = "event: message\ndata: #{response_json}\n\n"
137
+ render body:, content_type: Mime::Type.lookup("text/event-stream").to_s
138
+ else
139
+ render json: response_json
140
+ end
141
+ end
142
+
143
+ def mcp_event_stream_requested?
144
+ request.headers["Accept"].to_s.include?("text/event-stream")
145
+ end
146
+
147
+ # ---- auth (presence only; real auth is per-tool) --------------------
148
+
149
+ # A token must be present; its validity is enforced per-tool. Extraction
150
+ # order: Bearer header, then X-MCP-Token, then ?token=.
151
+ def mcp_require_token!
152
+ return if mcp_extract_token.present?
153
+
154
+ mcp_render_unauthorized("Missing authorization token")
155
+ end
156
+
157
+ def mcp_extract_token
158
+ auth_header = request.headers["Authorization"]
159
+ return auth_header.sub("Bearer ", "") if auth_header&.start_with?("Bearer ")
160
+
161
+ request.headers["X-MCP-Token"].presence || params[:token].presence
162
+ end
163
+
164
+ # ---- session lifecycle ----------------------------------------------
165
+
166
+ # POST: create a session on `initialize`, otherwise require an existing one.
167
+ def mcp_resolve_session!
168
+ methods = mcp_methods_from(mcp_parsed_body)
169
+
170
+ if methods.include?("initialize")
171
+ @mcp_session = McpToolkit::Session.create!(config: mcp_config)
172
+ else
173
+ @mcp_session = McpToolkit::Session.find(request.headers[SESSION_HEADER], config: mcp_config)
174
+ return mcp_render_session_not_found if @mcp_session.nil?
175
+ end
176
+
177
+ response.headers[SESSION_HEADER] = @mcp_session.id
178
+ end
179
+
180
+ def mcp_methods_from(request_body)
181
+ # Array.wrap (not Kernel#Array): a single JSON-RPC Hash must wrap to
182
+ # [hash], whereas Kernel#Array(hash) would explode it into [[k, v], ...].
183
+ Array.wrap(request_body).filter_map { |req| req.is_a?(Hash) ? req["method"] : nil }
184
+ end
185
+
186
+ def mcp_parsed_body
187
+ return @mcp_parsed_body if defined?(@mcp_parsed_body)
188
+
189
+ @mcp_parsed_body = JSON.parse(request.body.read)
190
+ rescue JSON::ParserError
191
+ @mcp_parsed_body = {}
192
+ end
193
+
194
+ # ---- error renders ---------------------------------------------------
195
+
196
+ def mcp_render_unauthorized(message)
197
+ render json: {
198
+ jsonrpc: "2.0",
199
+ id: nil,
200
+ error: { code: -32_000, message: "Unauthorized: #{message}" }
201
+ }, status: :unauthorized
202
+ end
203
+
204
+ def mcp_render_session_not_found
205
+ mcp_log_session_not_found
206
+ render json: {
207
+ jsonrpc: "2.0",
208
+ id: nil,
209
+ error: { code: -32_001, message: "Session not found or expired" }
210
+ }, status: :not_found
211
+ end
212
+
213
+ # Warns (greppable, no id/token) when a POST arrives with no matching session.
214
+ # The common cause is a session created on one process but looked up on another
215
+ # because `cache_store` isn't a shared store — invisible otherwise, since the
216
+ # caller just sees a 404. Records only whether a session-id header was PRESENT so
217
+ # a header-missing client bug is distinguishable from a cache misconfiguration.
218
+ def mcp_log_session_not_found
219
+ header_present = request.headers[SESSION_HEADER].present?
220
+ mcp_logger.warn(
221
+ "[McpToolkit] MCP session not found or expired " \
222
+ "(#{SESSION_HEADER} header present: #{header_present}). If sessions are created but not " \
223
+ "found, cache_store is likely not shared across processes (set it to a shared store, e.g. Rails.cache)."
224
+ )
225
+ end
226
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "did_you_mean"
5
+ rescue LoadError
6
+ nil
7
+ end
8
+
9
+ # Builds the McpToolkit::Registry::UnknownResource message from a bad name and the
10
+ # registered resource names: it states the bad name, then (a) suggests the nearest
11
+ # registered name(s) for a near-miss — via Ruby's stdlib DidYouMean spell checker
12
+ # when available, else a dependency-free prefix/substring + edit-distance fallback —
13
+ # and (b), when the catalog is short, lists them all, so a caller (typically an MCP
14
+ # agent that guessed a name) can self-correct without another round-trip to the
15
+ # `resources` tool.
16
+ class McpToolkit::UnknownResourceMessage
17
+ FULL_LIST_MAX = 10
18
+ MAX_SUGGESTIONS = 3
19
+
20
+ def initialize(name, resource_names)
21
+ @name = name
22
+ @resource_names = resource_names
23
+ end
24
+
25
+ def build
26
+ message = "unknown resource: #{@name.inspect}"
27
+ return message if @resource_names.empty?
28
+
29
+ suggestions = suggestions_for(@name.to_s, @resource_names)
30
+ message += ". Did you mean #{quote_join(suggestions)}?" if suggestions.any?
31
+ message += " Registered resources: #{quote_join(@resource_names.sort)}." if @resource_names.size <= FULL_LIST_MAX
32
+ message
33
+ end
34
+
35
+ private
36
+
37
+ def suggestions_for(name, names)
38
+ if defined?(DidYouMean::SpellChecker)
39
+ Array(DidYouMean::SpellChecker.new(dictionary: names).correct(name)).first(MAX_SUGGESTIONS)
40
+ else
41
+ fallback_suggestions(name, names)
42
+ end
43
+ end
44
+
45
+ def fallback_suggestions(name, names)
46
+ target = name.downcase
47
+ matches = names.select { |candidate| near_miss?(candidate.downcase, target) }
48
+ matches.sort_by { |candidate| levenshtein(candidate.downcase, target) }.first(MAX_SUGGESTIONS)
49
+ end
50
+
51
+ def near_miss?(candidate, target)
52
+ candidate.start_with?(target) || target.start_with?(candidate) ||
53
+ candidate.include?(target) || levenshtein(candidate, target) <= 2
54
+ end
55
+
56
+ def levenshtein(source, target)
57
+ return target.length if source.empty?
58
+ return source.length if target.empty?
59
+
60
+ previous = (0..target.length).to_a
61
+ source.each_char.with_index do |source_char, row|
62
+ current = [row + 1]
63
+ target.each_char.with_index do |target_char, col|
64
+ cost = source_char == target_char ? 0 : 1
65
+ current << [previous[col + 1] + 1, current[col] + 1, previous[col] + cost].min
66
+ end
67
+ previous = current
68
+ end
69
+ previous.last
70
+ end
71
+
72
+ def quote_join(names)
73
+ names.map { |name| "\"#{name}\"" }.join(", ")
74
+ end
75
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module McpToolkit
4
+ VERSION = "0.3.0"
5
+ end