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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +43 -0
- data/.rubocop.yml +98 -0
- data/CHANGELOG.md +123 -0
- data/LICENSE.txt +21 -0
- data/README.md +301 -0
- data/Rakefile +21 -0
- data/app/controllers/mcp_toolkit/server_controller.rb +19 -0
- data/config/routes.rb +18 -0
- data/lib/mcp_toolkit/auth/authenticator.rb +99 -0
- data/lib/mcp_toolkit/auth/authority.rb +75 -0
- data/lib/mcp_toolkit/auth/authority_server_client.rb +50 -0
- data/lib/mcp_toolkit/auth/introspection.rb +162 -0
- data/lib/mcp_toolkit/configuration.rb +209 -0
- data/lib/mcp_toolkit/engine.rb +21 -0
- data/lib/mcp_toolkit/errors/base.rb +10 -0
- data/lib/mcp_toolkit/errors/configuration_error.rb +5 -0
- data/lib/mcp_toolkit/errors/invalid_params.rb +5 -0
- data/lib/mcp_toolkit/errors/unauthorized.rb +5 -0
- data/lib/mcp_toolkit/field_selection.rb +93 -0
- data/lib/mcp_toolkit/filtering.rb +152 -0
- data/lib/mcp_toolkit/get_executor.rb +32 -0
- data/lib/mcp_toolkit/list_executor.rb +137 -0
- data/lib/mcp_toolkit/registry.rb +67 -0
- data/lib/mcp_toolkit/resource.rb +129 -0
- data/lib/mcp_toolkit/resource_schema.rb +163 -0
- data/lib/mcp_toolkit/serialization.rb +62 -0
- data/lib/mcp_toolkit/serializer/base.rb +285 -0
- data/lib/mcp_toolkit/server.rb +44 -0
- data/lib/mcp_toolkit/session.rb +46 -0
- data/lib/mcp_toolkit/sql_sanitizer.rb +14 -0
- data/lib/mcp_toolkit/token_kinds.rb +14 -0
- data/lib/mcp_toolkit/tools/base.rb +100 -0
- data/lib/mcp_toolkit/tools/get.rb +57 -0
- data/lib/mcp_toolkit/tools/list.rb +83 -0
- data/lib/mcp_toolkit/tools/resource_schema.rb +40 -0
- data/lib/mcp_toolkit/tools/resources.rb +27 -0
- data/lib/mcp_toolkit/transport/controller_methods.rb +226 -0
- data/lib/mcp_toolkit/unknown_resource_message.rb +75 -0
- data/lib/mcp_toolkit/version.rb +5 -0
- data/lib/mcp_toolkit.rb +118 -0
- data/sig/mcp_toolkit.rbs +4 -0
- 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
|