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,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Builds a machine-readable schema for a single registered resource: its
4
+ # serialized attributes (the response shape) with column types, and its
5
+ # relationships (the `links` shape). Powers the `resource_schema` discovery tool
6
+ # so an MCP client can learn a resource's shape without trial and error.
7
+ #
8
+ # Read-only with standard filters (ids/updated_since/limit/offset) plus the
9
+ # resource's declared equality filters.
10
+ class McpToolkit::ResourceSchema
11
+ TYPE_FORMATS = {
12
+ datetime: "ISO 8601",
13
+ date: "ISO 8601",
14
+ decimal: "number",
15
+ float: "number",
16
+ integer: "integer",
17
+ boolean: "true/false",
18
+ string: "string",
19
+ text: "string"
20
+ }.freeze
21
+ COMPUTED_TYPE = "computed"
22
+ STANDARD_FILTERS = %w[ids updated_since limit offset].freeze
23
+
24
+ # `registry` is used to resolve each relationship to the registered resource it
25
+ # points at (see #relationships). Defaults to the process-wide registry; the
26
+ # `resource_schema` tool passes the active config's registry explicitly.
27
+ def self.call(resource, registry: McpToolkit.registry)
28
+ new(resource, registry:).call
29
+ end
30
+
31
+ def initialize(resource, registry: McpToolkit.registry)
32
+ @resource = resource
33
+ @model = resource.model
34
+ @registry = registry
35
+ end
36
+
37
+ def call
38
+ {
39
+ name: resource.name,
40
+ description: resource.description,
41
+ attributes:,
42
+ relationships:,
43
+ standard_filters: STANDARD_FILTERS,
44
+ filters:
45
+ }
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :resource, :model, :registry
51
+
52
+ def attributes
53
+ resource.attribute_names.map { |name| attribute_schema(name) }
54
+ end
55
+
56
+ def attribute_schema(name)
57
+ type = column_type(name)
58
+ {
59
+ name:,
60
+ type: type ? type.to_s : COMPUTED_TYPE,
61
+ format: type ? TYPE_FORMATS[type] : nil,
62
+ filterable: filterable_column_for(name).present?
63
+ }.compact
64
+ end
65
+
66
+ # Per-attribute equality filters this resource accepts on the `list` tool's
67
+ # `filter` argument. Each entry is the request-facing key, the backing column
68
+ # it matches against, and the column's type — self-describing so an MCP client
69
+ # can construct a valid filter without trial and error.
70
+ def filters
71
+ resource.filterable_columns.sort.map do |request_key, column|
72
+ type = column_type(column)
73
+ {
74
+ key: request_key,
75
+ column:,
76
+ type: type ? type.to_s : COMPUTED_TYPE,
77
+ format: type ? TYPE_FORMATS[type] : nil
78
+ }.compact
79
+ end
80
+ end
81
+
82
+ # Backing column for a serialized attribute that is also a filter key, if any.
83
+ # A filter key may be a public alias (e.g. booking_id -> synced_booking_id) so
84
+ # we match on either the request key or the column.
85
+ def filterable_column_for(attribute_name)
86
+ resource.filterable_columns.find do |request_key, column|
87
+ request_key.to_s == attribute_name.to_s || column.to_s == attribute_name.to_s
88
+ end
89
+ end
90
+
91
+ def relationships
92
+ resource.association_descriptors.map { |association| relationship_schema(association) }
93
+ end
94
+
95
+ # One relationship entry. Beyond the link key/kind/polymorphic flag it now also
96
+ # names the `target_resource` — the registered resource this link resolves to,
97
+ # callable via `list`/`get` — so e.g. a `scheduled_notifications.notification`
98
+ # link is discoverably the `notifications` resource rather than a name to guess.
99
+ # It is omitted (additive/backward-compatible) when the target can't be resolved
100
+ # (e.g. a polymorphic link).
101
+ def relationship_schema(association)
102
+ target = target_resource_for(association)
103
+ {
104
+ name: association.links_key,
105
+ kind: association.type.to_s,
106
+ polymorphic: association.polymorphic || false,
107
+ target_resource: target&.name
108
+ }.compact
109
+ end
110
+
111
+ # The registered resource an association points at, or nil. Polymorphic links
112
+ # have no single target, so they are left unresolved (the `polymorphic` flag and
113
+ # the record's `{id:, type:}` link value already carry the target type). Prefers
114
+ # an explicit target serializer's model, then falls back to matching the link's
115
+ # (pluralized) name against registered resource names.
116
+ def target_resource_for(association)
117
+ return nil if association.polymorphic
118
+
119
+ target_from_serializer(association) || target_from_name(association)
120
+ end
121
+
122
+ def target_from_serializer(association)
123
+ serializer = association.serializer
124
+ return nil unless serializer.respond_to?(:model_class)
125
+
126
+ target_model = safe_model_class(serializer)
127
+ return nil unless target_model
128
+
129
+ registry.resources.find { |candidate| candidate.model == target_model }
130
+ end
131
+
132
+ def target_from_name(association)
133
+ candidate_names(association).each do |candidate|
134
+ match = registry.find(candidate)
135
+ return match if match
136
+ end
137
+ nil
138
+ end
139
+
140
+ # Names to try against the registry for a link, closest spelling first: the link
141
+ # key and association name as declared, then their pluralizations (resources are
142
+ # registered under plural names, so a singular `notification` link resolves to
143
+ # the `notifications` resource).
144
+ def candidate_names(association)
145
+ [association.links_key, association.name.to_s].flat_map do |base|
146
+ [base, base.pluralize]
147
+ end.uniq
148
+ end
149
+
150
+ def safe_model_class(serializer)
151
+ serializer.model_class
152
+ rescue StandardError
153
+ nil
154
+ end
155
+
156
+ # Reads an attribute's DB column type, tolerating models that don't expose
157
+ # `columns_hash` (returns nil => the attribute is reported as "computed").
158
+ def column_type(name)
159
+ return nil unless model.respond_to?(:columns_hash)
160
+
161
+ model.columns_hash[name.to_s]&.type
162
+ end
163
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bridges the executors to a resource's serializer while honoring a sparse
4
+ # McpToolkit::FieldSelection. Built per call with the serializer and the (possibly
5
+ # nil) selection; `#one` / `#collection` then mirror the serializer contract. This
6
+ # is the single place that keeps the injectable serializer contract intact as
7
+ # sparse fieldsets are added:
8
+ #
9
+ # * selection nil (the default) -> the serializer is called EXACTLY as before
10
+ # (no `fields:` kwarg), so existing behavior and injected serializers are
11
+ # untouched.
12
+ # * selection present, serializer declares a `fields:` keyword (the gem's Base)
13
+ # -> applied NATIVELY, skipping the compute for unselected members.
14
+ # * selection present, serializer predates the kwarg -> the full output is
15
+ # PRUNED to the selection, so any contract-satisfying serializer stays
16
+ # sparse-able without change.
17
+ class McpToolkit::Serialization
18
+ # `Method#parameters` types that mean a keyword the caller may pass by name.
19
+ FIELDS_KEYWORD_TYPES = %i[key keyreq].freeze
20
+
21
+ def initialize(serializer, selection)
22
+ @serializer = serializer
23
+ @selection = selection
24
+ end
25
+
26
+ def one(record, scope:)
27
+ return @serializer.serialize_one(record, scope:) if @selection.nil?
28
+
29
+ if accepts_fields?(:serialize_one)
30
+ @serializer.serialize_one(record, scope:, fields: @selection.names)
31
+ else
32
+ result = @serializer.serialize_one(record, scope:)
33
+ result && @selection.prune_record(result)
34
+ end
35
+ end
36
+
37
+ def collection(records, scope:, total_count:, limit:, offset:)
38
+ return full_collection(records, scope:, total_count:, limit:, offset:) if @selection.nil?
39
+
40
+ if accepts_fields?(:serialize_collection)
41
+ @serializer.serialize_collection(records, scope:, total_count:, limit:, offset:, fields: @selection.names)
42
+ else
43
+ @selection.prune_collection(full_collection(records, scope:, total_count:, limit:, offset:))
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def full_collection(records, scope:, total_count:, limit:, offset:)
50
+ @serializer.serialize_collection(records, scope:, total_count:, limit:, offset:)
51
+ end
52
+
53
+ # True only when the method declares an EXPLICIT `fields:` keyword. A serializer
54
+ # that merely absorbs `**kwargs` is deliberately NOT treated as fields-aware — it
55
+ # would ignore the selection and silently return the full shape — so it falls
56
+ # through to output pruning, which guarantees the response is actually narrowed.
57
+ def accepts_fields?(method_name)
58
+ @serializer.method(method_name).parameters.any? do |type, name|
59
+ name == :fields && FIELDS_KEYWORD_TYPES.include?(type)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The DEFAULT serializer base shipped by the toolkit. A self-contained
4
+ # implementation of the subset of an AMS-style serializer the MCP wire format
5
+ # depends on, with NO dependency on `active_model_serializers` / `fast_jsonapi`.
6
+ #
7
+ # ## The injection contract
8
+ #
9
+ # The executors (`ListExecutor` / `GetExecutor`) only ever call two class
10
+ # methods on a resource's serializer:
11
+ #
12
+ # serializer.serialize_one(record, scope:)
13
+ # # => Hash (a single record's shape), or nil for a nil record
14
+ #
15
+ # serializer.serialize_collection(records, scope:, total_count:, limit:, offset:)
16
+ # # => { <root_key> => [ <record_hash>, ... ],
17
+ # # meta: { total_count:, limit:, offset: } }
18
+ #
19
+ # ANY class implementing those two methods can be registered as a resource's
20
+ # serializer — that is the seam that lets an app's existing serializers slot in
21
+ # unchanged alongside this base. The `resource_schema` tool additionally reads
22
+ # `declared_attributes` and
23
+ # `declared_associations` off the serializer (for shape discovery); a custom
24
+ # serializer that wants to power `resource_schema` should expose those too, but
25
+ # they are not required for `get` / `list`.
26
+ #
27
+ # ## Sparse fieldsets (optional)
28
+ #
29
+ # Both entry points also accept an OPTIONAL `fields:` keyword — an array of the
30
+ # attribute and/or relationship link-key names to include (JSON:API's sparse
31
+ # fieldset; the two share one flat namespace). `fields: nil` (the default) means
32
+ # "everything", so the contract above is unchanged for callers that omit it. When
33
+ # a subset is given, only those members are emitted AND only the selected
34
+ # relationships are loaded (unselected `has_many` links are never queried). A
35
+ # serializer that does NOT declare a `fields:` keyword still supports sparse
36
+ # fieldsets — McpToolkit::Serialization prunes its output instead — so honoring
37
+ # `fields:` natively is a performance optimization, not a contract requirement.
38
+ #
39
+ # `scope` is whatever the serializer needs (typically the account); it may be
40
+ # nil for models without translations.
41
+ #
42
+ # ## Output shape
43
+ #
44
+ # A single record serializes to:
45
+ #
46
+ # { <attr> => <value>, ..., "links" => { "<assoc>" => <id|[ids]|{id:,type:}|nil> } }
47
+ #
48
+ # * Declared `attributes` are emitted as symbol keys, in declaration order
49
+ # (an instance method named after the attribute overrides the column value).
50
+ # * `"links"` is a string key whose value is a Hash with string keys, one per
51
+ # declared association, sorted alphabetically.
52
+ # - has_one / belongs_to whose FK lives on the record => the raw id (or nil)
53
+ # - polymorphic has_one / belongs_to => { id: <id>, type: <type> }
54
+ # - has_many => a sorted Array of associated ids ([] when none)
55
+ # * created_at / updated_at, when present, are rendered as iso8601(6).
56
+ #
57
+ # A collection serializes to:
58
+ #
59
+ # { <plural_resource_name>: [ <record_hash>, ... ],
60
+ # meta: { total_count:, limit:, offset: } }
61
+ class McpToolkit::Serializer::Base
62
+ TIMESTAMP_COLUMNS = %i[created_at updated_at].freeze
63
+ HIGH_PRECISION_FOR_TIMESTAMPS = 6
64
+
65
+ # ---- class-level DSL -------------------------------------------------
66
+
67
+ Association = Struct.new(:name, :type, :key, :serializer, :polymorphic, :foreign_key, keyword_init: true) do
68
+ # Public-facing key used inside the `links` hash.
69
+ def links_key
70
+ (key || name).to_s
71
+ end
72
+ end
73
+
74
+ def self.attributes(*names)
75
+ names.each { |name| declared_attributes << name.to_sym }
76
+ end
77
+
78
+ # belongs_to / has_one - single id (or {id:,type:} when polymorphic).
79
+ #
80
+ # `foreign_key:` overrides the FK method read for the id (defaults to
81
+ # `<name>_id`). Use it when the model's FK column doesn't follow the
82
+ # `<name>_id` convention - e.g.
83
+ # `has_one :account, foreign_key: :synced_account_id` so the link reports
84
+ # the central account id straight off the already-loaded column.
85
+ def self.has_one(name, key: nil, root: nil, serializer: nil, polymorphic: false, foreign_key: nil)
86
+ declared_associations << Association.new(
87
+ name: name.to_sym, type: :has_one, key: key || root, serializer:, polymorphic:, foreign_key:
88
+ )
89
+ end
90
+
91
+ # has_many / has_and_belongs_to_many - sorted array of ids.
92
+ def self.has_many(name, key: nil, root: nil, serializer: nil)
93
+ declared_associations << Association.new(
94
+ name: name.to_sym, type: :has_many, key: key || root, serializer:, polymorphic: false
95
+ )
96
+ end
97
+
98
+ # Declares attributes whose value is a `{ locale => translation }` hash.
99
+ # An instance method is defined for each attribute that delegates to
100
+ # `#translate`. Only meaningful for Globalize models; harmless otherwise
101
+ # (returns {}).
102
+ def self.translates(*names)
103
+ names.each do |name|
104
+ declared_attributes << name.to_sym unless declared_attributes.include?(name.to_sym)
105
+ define_method(name) { translate(name) }
106
+ end
107
+ end
108
+
109
+ def self.declared_attributes
110
+ @declared_attributes ||= []
111
+ end
112
+
113
+ def self.declared_associations
114
+ @declared_associations ||= []
115
+ end
116
+
117
+ # ---- entry points used by the executors (the injection contract) -----
118
+
119
+ # Serialize a single record to its attributes+links hash. nil-safe.
120
+ # `fields:` (optional) restricts the output to a sparse fieldset — see the
121
+ # class-level docs.
122
+ def self.serialize_one(record, scope: nil, fields: nil)
123
+ return nil if record.nil?
124
+
125
+ new(record, scope:).serializable_hash(fields:)
126
+ end
127
+
128
+ # Serialize an array of records to the index wrapper, keyed by the
129
+ # pluralized resource name, with a `meta` pagination block. `fields:` (optional)
130
+ # restricts every row to a sparse fieldset — see the class-level docs.
131
+ def self.serialize_collection(records, scope: nil, total_count: nil, limit: nil, offset: nil, fields: nil)
132
+ rows = Array(records).map { |record| new(record, scope:).serializable_hash(fields:) }
133
+ {
134
+ root_key => rows,
135
+ meta: { total_count: total_count.nil? ? rows.size : total_count, limit:, offset: }
136
+ }
137
+ end
138
+
139
+ # Pluralized resource name used as the collection root key, derived from
140
+ # the serialized model (`model.model_name.plural`).
141
+ def self.root_key
142
+ model_class.model_name.plural.to_sym
143
+ end
144
+
145
+ # Infer the serialized model from the serializer class name by stripping a
146
+ # trailing "Serializer" and the host namespace, e.g.
147
+ # Mcp::NotificationSerializer -> Notification
148
+ # Mcp::PushNotifications::FilterSerializer -> PushNotifications::Filter
149
+ # Subclasses whose name doesn't follow the convention set `model_class`.
150
+ def self.model_class
151
+ @model_class ||= begin
152
+ without_suffix = name.delete_suffix("Serializer")
153
+ # Drop the leading serializer namespace segment (e.g. "Mcp::") so the
154
+ # remainder names the model. If there is no namespace, use as-is.
155
+ without_namespace = without_suffix.sub(/\A[^:]+::/, "")
156
+ (without_namespace.empty? ? without_suffix : without_namespace).constantize
157
+ end
158
+ end
159
+
160
+ # Lets subclasses point at a model whose name doesn't follow the
161
+ # convention (e.g. namespacing differences). Written as an explicit class
162
+ # method (not `attr_writer`, which would define an instance writer) to set the
163
+ # class-level @model_class the convention-inference memoizes.
164
+ def self.model_class=(klass) # rubocop:disable Style/TrivialAccessors
165
+ @model_class = klass
166
+ end
167
+
168
+ # ---- instance API ----------------------------------------------------
169
+
170
+ attr_reader :object, :scope
171
+
172
+ def initialize(object, scope: nil)
173
+ @object = object
174
+ @scope = scope
175
+ end
176
+
177
+ # `fields:` (optional) is a sparse fieldset — an array of attribute and/or
178
+ # relationship link-key names to include. nil (default) emits everything.
179
+ def serializable_hash(fields: nil)
180
+ selected = fields&.map(&:to_sym)
181
+ hash = {}
182
+ self.class.declared_attributes.each do |attr|
183
+ next if selected && !selected.include?(attr)
184
+
185
+ hash[attr] = read_attribute(attr)
186
+ end
187
+ apply_high_precision_timestamps(hash)
188
+ link_hash = links(selected)
189
+ hash["links"] = link_hash unless link_hash.nil?
190
+ hash
191
+ end
192
+ alias as_json serializable_hash
193
+
194
+ private
195
+
196
+ def read_attribute(attr)
197
+ # An instance method named after the attribute overrides the column value
198
+ # (AMS convention). Globalize `translates` uses exactly this hook.
199
+ if respond_to?(attr, true) && method(attr).owner != McpToolkit::Serializer::Base
200
+ public_send(attr)
201
+ else
202
+ object.public_send(attr)
203
+ end
204
+ end
205
+
206
+ def apply_high_precision_timestamps(hash)
207
+ TIMESTAMP_COLUMNS.each do |column|
208
+ value = hash[column]
209
+ hash[column] = value.iso8601(HIGH_PRECISION_FOR_TIMESTAMPS) if value.present? && value.respond_to?(:iso8601)
210
+ end
211
+ end
212
+
213
+ # Builds the `links` hash: association links_key => ids, sorted.
214
+ #
215
+ # `selected` is a sparse fieldset (array of symbols) or nil. With nil the block
216
+ # is always returned (possibly empty `{}`), preserving the default shape. Under
217
+ # a selection only the selected associations are included, AND the whole block
218
+ # is OMITTED (returns nil) when none were selected — so narrowing to a few
219
+ # attributes drops the `links` noise entirely.
220
+ def links(selected = nil)
221
+ associations = self.class.declared_associations
222
+ if selected
223
+ associations = associations.select { |association| selected.include?(association.links_key.to_sym) }
224
+ return nil if associations.empty?
225
+ end
226
+ pairs = associations.map do |association|
227
+ [association.links_key, serialize_ids(association)]
228
+ end
229
+ pairs.sort_by(&:first).to_h
230
+ end
231
+
232
+ # Serializes an association to its id(s):
233
+ # * FK present on the record -> the raw id (polymorphic -> {id:,type:})
234
+ # * otherwise load the association -> sorted array of ids (has_many)
235
+ # or single id (has_one).
236
+ def serialize_ids(association)
237
+ fk_method = association.foreign_key || :"#{association.name}_id"
238
+
239
+ if object.respond_to?(fk_method)
240
+ if association.polymorphic
241
+ { id: object.public_send(fk_method), type: object.public_send(:"#{association.name}_type") }
242
+ else
243
+ object.public_send(fk_method)
244
+ end
245
+ else
246
+ associated = object.public_send(association.name)
247
+ if associated.respond_to?(:to_ary) || associated.respond_to?(:pluck)
248
+ associated.pluck(:id).sort
249
+ elsif associated
250
+ associated.id
251
+ end
252
+ end
253
+ end
254
+
255
+ # Globalize-backed translation: `{ locale => value }`, restricted to the
256
+ # account's selected locales when a scope account is present. Returns {} when
257
+ # the model is not translatable.
258
+ def translate(attribute)
259
+ return {} unless object.respond_to?(:"#{attribute}_translations")
260
+
261
+ translations = object.public_send(:"#{attribute}_translations") || {}
262
+ locales = scope_locales
263
+ result = {}
264
+ translations.each do |locale, value|
265
+ locale = locale.to_sym
266
+ next if locales&.exclude?(locale)
267
+ next if value.blank?
268
+
269
+ result[locale] = value
270
+ end
271
+ result
272
+ end
273
+
274
+ # Locales to restrict translations to. nil means "no restriction" (emit all
275
+ # available translations).
276
+ def scope_locales
277
+ return nil if scope.nil?
278
+ return nil unless scope.respond_to?(:selected_locales)
279
+
280
+ selected = scope.selected_locales
281
+ return nil if selected.blank?
282
+
283
+ Array(selected).map(&:to_sym)
284
+ end
285
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Builds the official-SDK `MCP::Server` for this app: the JSON-RPC dispatcher
4
+ # with the toolkit's generic tools registered. The transport / session / HTTP
5
+ # layer lives in McpToolkit::Transport::ControllerMethods; this is purely the
6
+ # gem's dispatcher with tools + the per-request server_context.
7
+ #
8
+ # The official `mcp` gem is the JSON-RPC core (per the 2026-06-18 architecture
9
+ # decision: standardize on the gem, wrapped, rather than a hand-rolled protocol).
10
+ module McpToolkit::Server
11
+ # The generic, registry-driven toolset every server gets. Apps that want
12
+ # additional bespoke tools pass them via `extra_tools:`.
13
+ GENERIC_TOOLS = [
14
+ McpToolkit::Tools::Resources,
15
+ McpToolkit::Tools::ResourceSchema,
16
+ McpToolkit::Tools::Get,
17
+ McpToolkit::Tools::List
18
+ ].freeze
19
+
20
+ # @param server_context [Hash] per-request context threaded to tools:
21
+ # :bearer_token, :header_account_id, :mcp_config, and (merged in by the gem)
22
+ # :_meta.
23
+ # @param config [McpToolkit::Configuration]
24
+ # @param extra_tools [Array<Class>] additional MCP::Tool subclasses to expose.
25
+ # @return [MCP::Server]
26
+ def self.build(server_context:, config: McpToolkit.config, extra_tools: [])
27
+ context = server_context.dup
28
+ context[:mcp_config] ||= config
29
+
30
+ kwargs = {
31
+ name: config.server_name,
32
+ version: config.server_version,
33
+ instructions: config.server_instructions,
34
+ tools: GENERIC_TOOLS + Array(extra_tools),
35
+ server_context: context
36
+ }
37
+ if config.protocol_version
38
+ kwargs[:configuration] =
39
+ MCP::Configuration.new(protocol_version: config.protocol_version)
40
+ end
41
+
42
+ MCP::Server.new(**kwargs)
43
+ end
44
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Server-side session for the MCP Streamable HTTP transport: created on
4
+ # `initialize`, identified by an opaque `Mcp-Session-Id` header the client echoes
5
+ # on every later request, stored in the configured cache with a sliding TTL.
6
+ #
7
+ # Cache-backed (rather than the gem's in-process StreamableHTTPTransport) so
8
+ # sessions survive across Puma workers and interoperate with a gateway's client.
9
+ # The cache store + TTL come from McpToolkit.config.
10
+ class McpToolkit::Session
11
+ CACHE_KEY_PREFIX = "mcp_toolkit:session:"
12
+
13
+ def self.create!(config: McpToolkit.config)
14
+ id = SecureRandom.uuid
15
+ config.cache_store.write(cache_key(id), { created_at: Time.now.to_i }, expires_in: config.session_ttl)
16
+ new(id:)
17
+ end
18
+
19
+ def self.find(id, config: McpToolkit.config)
20
+ return nil if id.to_s.empty?
21
+
22
+ data = config.cache_store.read(cache_key(id))
23
+ return nil unless data
24
+
25
+ # Sliding expiry: bump TTL on every successful lookup.
26
+ config.cache_store.write(cache_key(id), data, expires_in: config.session_ttl)
27
+ new(id:)
28
+ end
29
+
30
+ def self.delete(id, config: McpToolkit.config)
31
+ return false if id.to_s.empty?
32
+
33
+ config.cache_store.delete(cache_key(id))
34
+ end
35
+
36
+ def self.cache_key(id)
37
+ "#{CACHE_KEY_PREFIX}#{id}"
38
+ end
39
+ private_class_method :cache_key
40
+
41
+ attr_reader :id
42
+
43
+ def initialize(id:)
44
+ @id = id
45
+ end
46
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Escapes LIKE wildcards (`%`, `_`, `\`) in a string so a `matches` /
4
+ # `does_not_match` filter value is matched literally rather than as a pattern.
5
+ #
6
+ # The default implementation delegates to ActiveRecord's
7
+ # `sanitize_sql_like` (production); it is injected via
8
+ # `Configuration#sql_sanitizer` so a non-Rails host (or a test) can supply its
9
+ # own. Filtering calls `config.sql_sanitizer.sanitize_sql_like(value)`.
10
+ class McpToolkit::SqlSanitizer
11
+ def sanitize_sql_like(string)
12
+ ActiveRecord::Base.sanitize_sql_like(string)
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The token "kind" domain values, shared by the collaborating auth objects so the
4
+ # string literals live in exactly one place. The authority emits these in the
5
+ # introspection payload (`kind`); the satellite reads them back
6
+ # (`Auth::Introspection::Result#accounts_user?` / `#superuser?`).
7
+ #
8
+ # ACCOUNTS_USER - a token bound to a single account.
9
+ # USER - a superuser / multi-account token (advertises its set via
10
+ # `account_ids`).
11
+ module McpToolkit::TokenKinds
12
+ ACCOUNTS_USER = "accounts_user"
13
+ USER = "user"
14
+ end