Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
## [0.2.0] - 2026-07-02

### Added

- **Sparse fieldsets** (JSON:API `fields[type]`) on the `get` and `list` tools. Pass
`fields` — an array of names or a comma-separated string — to return only the named
attributes and/or relationships, shrinking the response (a token win for MCP clients).
Attribute and relationship names share one flat namespace; `resource_schema` advertises
the valid values. Unknown names are rejected with `InvalidParams` (consistent with
unknown filter keys), so a typo is actionable rather than silently dropped.
- `McpToolkit::Serializer::Base` honors the selection NATIVELY: `serialize_one` /
`serialize_collection` / `serializable_hash` take an optional `fields:` keyword, and
unselected `has_many` relationships are never loaded (a query win, not just a payload
win). Under a selection the `links` block is omitted entirely when no relationship is
selected.
- `McpToolkit::FieldSelection` parses + validates the request; `McpToolkit::Serialization`
bridges the executors to the serializer.
- **Backward compatible.** Omitting `fields` returns the full shape exactly as before. An
injected serializer that does NOT declare a `fields:` keyword still supports sparse
fieldsets — the toolkit prunes its output to the requested `fields` — so the serializer
injection contract is unchanged.

## [0.1.0] - 2026-06-28

Initial extraction from two independently-grown internal MCP servers into a single
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,13 @@ serializer.serialize_collection(records, scope:, total_count:, limit:, offset:)
Any class satisfying that contract slots in — including an app's existing
serializers. Register it directly:

> **Sparse fieldsets.** Both methods also accept an optional `fields:` keyword (an
> array of attribute/relationship names) so `get` / `list` can return a subset of
> a record's shape. Honoring it natively — the bundled base does — skips computing
> the unselected members; a serializer that ignores it still works, because the
> toolkit prunes its output to the requested `fields` instead. Omitting `fields:`
> (the default) returns the full shape, so this is fully backward-compatible.

```ruby
McpToolkit.registry.register(:bookings) do
model Booking
Expand Down
93 changes: 93 additions & 0 deletions lib/mcp_toolkit/field_selection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# frozen_string_literal: true

# A parsed, validated sparse-fieldset request (JSON:API's `fields[type]`) for a
# single resource. Requested names share ONE flat namespace covering both declared
# ATTRIBUTES and relationship link keys — exactly as JSON:API conflates them.
#
# Built by the List/Get executors from the tool's `fields` argument. A nil
# selection (blank/absent `fields`) means "all fields": the executors skip it
# entirely and serialize as before, so the default path is completely untouched.
#
# A present selection is applied one of two ways, decided by McpToolkit::Serialization:
# * NATIVELY — `names` is passed to a serializer whose `serialize_one` /
# `serialize_collection` declares a `fields:` keyword (the gem's Base does),
# so unselected attributes and relationships are never computed at all.
# * By PRUNING the fully-serialized hash (`prune_record` / `prune_collection`)
# for an injected serializer that predates the `fields:` kwarg — a pure
# shape-level filter, so ANY contract-satisfying serializer stays sparse-able.
class McpToolkit::FieldSelection
LINKS_KEY = "links"

# Parse the raw tool argument (a comma-separated string OR an array of names)
# into a selection, or nil when nothing was requested. Validates the requested
# names against the resource's known members when the resource can describe them
# (the Base serializer exposes `declared_attributes`); an unknown name is a
# clean InvalidParams so a typo is actionable rather than silently dropped.
def self.build(resource:, raw:)
names = parse(raw)
return nil if names.empty?

new(resource:, names:).tap(&:validate!)
end

# Normalizes a CSV string or an array into a de-duplicated list of symbols.
def self.parse(raw)
list = raw.is_a?(Array) ? raw : raw.to_s.split(",")
list.map { |name| name.to_s.strip }.reject(&:empty?).map(&:to_sym).uniq
end

# The validated requested field names (symbols), passed to a fields-aware
# serializer as its `fields:` argument.
attr_reader :names

def initialize(resource:, names:)
@resource = resource
@names = names
end

# Raises InvalidParams if any requested name is neither a declared attribute nor
# a relationship link key. Skipped for serializers that can't describe their
# members (resource_schema degrades the same way) — those are pruned leniently.
def validate!
return unless @resource.serializer.respond_to?(:declared_attributes)

unknown = @names - known_members
return if unknown.empty?

selectable = known_members.map(&:to_s).sort.join(", ").presence || "(none)"
raise McpToolkit::Errors::InvalidParams,
"unknown field(s): #{unknown.join(", ")}. Selectable fields for this resource: #{selectable}"
end

# Prune a single serialized record hash down to the requested members.
# Shape-driven (needs no serializer metadata): every key is an attribute except
# the string `"links"` block, which is itself narrowed to the requested link
# keys and dropped entirely when nothing under it was requested.
def prune_record(hash)
requested = @names.map(&:to_s)
hash.each_with_object({}) do |(key, value), pruned|
if key.to_s == LINKS_KEY
links = (value || {}).select { |link_key, _| requested.include?(link_key.to_s) }
pruned[LINKS_KEY] = links unless links.empty?
elsif requested.include?(key.to_s)
pruned[key] = value
end
end
end

# Prune the collection wrapper: each array value holds the rows (prune each);
# the `meta` hash and any other non-array entry pass through untouched.
def prune_collection(wrapper)
wrapper.transform_values do |value|
value.is_a?(Array) ? value.map { |row| prune_record(row) } : value
end
end

private

def known_members
@known_members ||=
@resource.attribute_names.map(&:to_sym) +
@resource.association_descriptors.map { |assoc| assoc.links_key.to_sym }
end
end
13 changes: 8 additions & 5 deletions lib/mcp_toolkit/get_executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,29 @@
# scoped relation so cross-scope ids are simply not found. Serializes via the
# resource's serializer.
class McpToolkit::GetExecutor
def self.call(resource:, scope_root:, id:)
new(resource:, scope_root:, id:).call
def self.call(resource:, scope_root:, id:, fields: nil)
new(resource:, scope_root:, id:, fields:).call
end

def initialize(resource:, scope_root:, id:)
def initialize(resource:, scope_root:, id:, fields: nil)
@resource = resource
@scope_root = scope_root
@id = id
@fields = fields
end

def call
raise McpToolkit::Errors::InvalidParams, "id is required" if id.blank?

# Validate the sparse fieldset BEFORE the lookup so a bad `fields` fails fast.
selection = McpToolkit::FieldSelection.build(resource:, raw: fields)
record = resource.resolve_relation(scope_root).find_by(id:)
raise McpToolkit::Errors::InvalidParams, "#{resource.name} not found for id=#{id}" unless record

resource.serializer.serialize_one(record, scope: scope_root)
McpToolkit::Serialization.new(resource.serializer, selection).one(record, scope: scope_root)
end

private

attr_reader :resource, :scope_root, :id
attr_reader :resource, :scope_root, :id, :fields
end
7 changes: 6 additions & 1 deletion lib/mcp_toolkit/list_executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
# `offset` filters plus the resource's declared per-attribute filters (equality
# AND operator-based — see McpToolkit::Filtering), and serializes via the
# resource's serializer, producing the `{ <root> => [...], meta: {...} }` wrapper.
# An optional `fields` param applies a sparse fieldset to each row (see
# McpToolkit::FieldSelection).
class McpToolkit::ListExecutor
DEFAULT_LIMIT = 25
MAX_LIMIT = 100
Expand All @@ -23,11 +25,14 @@ def initialize(resource:, scope_root:, params:)
end

def call
# Validate the sparse fieldset BEFORE running the query so a bad `fields`
# fails fast rather than after a needless count + fetch.
selection = McpToolkit::FieldSelection.build(resource:, raw: params[:fields])
relation = build_relation
total_count = relation.count
rows = paginate(relation).to_a

resource.serializer.serialize_collection(
McpToolkit::Serialization.new(resource.serializer, selection).collection(
rows, scope: scope_root, total_count:, limit:, offset:
)
end
Expand Down
62 changes: 62 additions & 0 deletions lib/mcp_toolkit/serialization.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

# Bridges the executors to a resource's serializer while honoring a sparse
# McpToolkit::FieldSelection. Built per call with the serializer and the (possibly
# nil) selection; `#one` / `#collection` then mirror the serializer contract. This
# is the single place that keeps the injectable serializer contract intact as
# sparse fieldsets are added:
#
# * selection nil (the default) -> the serializer is called EXACTLY as before
# (no `fields:` kwarg), so existing behavior and injected serializers are
# untouched.
# * selection present, serializer declares a `fields:` keyword (the gem's Base)
# -> applied NATIVELY, skipping the compute for unselected members.
# * selection present, serializer predates the kwarg -> the full output is
# PRUNED to the selection, so any contract-satisfying serializer stays
# sparse-able without change.
class McpToolkit::Serialization
# `Method#parameters` types that mean a keyword the caller may pass by name.
FIELDS_KEYWORD_TYPES = %i[key keyreq].freeze

def initialize(serializer, selection)
@serializer = serializer
@selection = selection
end

def one(record, scope:)
return @serializer.serialize_one(record, scope:) if @selection.nil?

if accepts_fields?(:serialize_one)
@serializer.serialize_one(record, scope:, fields: @selection.names)
else
result = @serializer.serialize_one(record, scope:)
result && @selection.prune_record(result)
end
end

def collection(records, scope:, total_count:, limit:, offset:)
return full_collection(records, scope:, total_count:, limit:, offset:) if @selection.nil?

if accepts_fields?(:serialize_collection)
@serializer.serialize_collection(records, scope:, total_count:, limit:, offset:, fields: @selection.names)
else
@selection.prune_collection(full_collection(records, scope:, total_count:, limit:, offset:))
end
end

private

def full_collection(records, scope:, total_count:, limit:, offset:)
@serializer.serialize_collection(records, scope:, total_count:, limit:, offset:)
end

# True only when the method declares an EXPLICIT `fields:` keyword. A serializer
# that merely absorbs `**kwargs` is deliberately NOT treated as fields-aware — it
# would ignore the selection and silently return the full shape — so it falls
# through to output pruning, which guarantees the response is actually narrowed.
def accepts_fields?(method_name)
@serializer.method(method_name).parameters.any? do |type, name|
name == :fields && FIELDS_KEYWORD_TYPES.include?(type)
end
end
end
50 changes: 41 additions & 9 deletions lib/mcp_toolkit/serializer/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@
# serializer that wants to power `resource_schema` should expose those too, but
# they are not required for `get` / `list`.
#
# ## Sparse fieldsets (optional)
#
# Both entry points also accept an OPTIONAL `fields:` keyword — an array of the
# attribute and/or relationship link-key names to include (JSON:API's sparse
# fieldset; the two share one flat namespace). `fields: nil` (the default) means
# "everything", so the contract above is unchanged for callers that omit it. When
# a subset is given, only those members are emitted AND only the selected
# relationships are loaded (unselected `has_many` links are never queried). A
# serializer that does NOT declare a `fields:` keyword still supports sparse
# fieldsets — McpToolkit::Serialization prunes its output instead — so honoring
# `fields:` natively is a performance optimization, not a contract requirement.
#
# `scope` is whatever the serializer needs (typically the account); it may be
# nil for models without translations.
#
Expand Down Expand Up @@ -105,16 +117,19 @@ def self.declared_associations
# ---- entry points used by the executors (the injection contract) -----

# Serialize a single record to its attributes+links hash. nil-safe.
def self.serialize_one(record, scope: nil)
# `fields:` (optional) restricts the output to a sparse fieldset — see the
# class-level docs.
def self.serialize_one(record, scope: nil, fields: nil)
return nil if record.nil?

new(record, scope:).serializable_hash
new(record, scope:).serializable_hash(fields:)
end

# Serialize an array of records to the index wrapper, keyed by the
# pluralized resource name, with a `meta` pagination block.
def self.serialize_collection(records, scope: nil, total_count: nil, limit: nil, offset: nil)
rows = Array(records).map { |record| new(record, scope:).serializable_hash }
# pluralized resource name, with a `meta` pagination block. `fields:` (optional)
# restricts every row to a sparse fieldset — see the class-level docs.
def self.serialize_collection(records, scope: nil, total_count: nil, limit: nil, offset: nil, fields: nil)
rows = Array(records).map { |record| new(record, scope:).serializable_hash(fields:) }
{
root_key => rows,
meta: { total_count: total_count.nil? ? rows.size : total_count, limit:, offset: }
Expand Down Expand Up @@ -159,13 +174,19 @@ def initialize(object, scope: nil)
@scope = scope
end

def serializable_hash
# `fields:` (optional) is a sparse fieldset — an array of attribute and/or
# relationship link-key names to include. nil (default) emits everything.
def serializable_hash(fields: nil)
selected = fields&.map(&:to_sym)
hash = {}
self.class.declared_attributes.each do |attr|
next if selected && !selected.include?(attr)

hash[attr] = read_attribute(attr)
end
apply_high_precision_timestamps(hash)
hash["links"] = links
link_hash = links(selected)
hash["links"] = link_hash unless link_hash.nil?
hash
end
alias as_json serializable_hash
Expand All @@ -190,8 +211,19 @@ def apply_high_precision_timestamps(hash)
end

# Builds the `links` hash: association links_key => ids, sorted.
def links
pairs = self.class.declared_associations.map do |association|
#
# `selected` is a sparse fieldset (array of symbols) or nil. With nil the block
# is always returned (possibly empty `{}`), preserving the default shape. Under
# a selection only the selected associations are included, AND the whole block
# is OMITTED (returns nil) when none were selected — so narrowing to a few
# attributes drops the `links` noise entirely.
def links(selected = nil)
associations = self.class.declared_associations
if selected
associations = associations.select { |association| selected.include?(association.links_key.to_sym) }
return nil if associations.empty?
end
pairs = associations.map do |association|
[association.links_key, serialize_ids(association)]
end
pairs.sort_by(&:first).to_h
Expand Down
16 changes: 14 additions & 2 deletions lib/mcp_toolkit/tools/get.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ class McpToolkit::Tools::Get < McpToolkit::Tools::Base
For tokens that span multiple accounts (superuser), pass `account_id` to pin the active
account; account-scoped tokens may omit it. The response mirrors the resource's record
shape (attributes + a `links` block).

Pass `fields` to return a sparse fieldset — the attributes and/or relationships you name
(as an array or comma-separated string), omitting everything else. Include "id" if you need
it. Valid names come from the resource's `resource_schema`; unknown names are rejected.
DESC

input_schema(
Expand All @@ -25,19 +29,27 @@ class McpToolkit::Tools::Get < McpToolkit::Tools::Base
account_id: {
type: "integer",
description: "Account to operate on. Required for superuser tokens; ignored otherwise."
},
fields: {
type: %w[array string],
items: { type: "string" },
description: "Sparse fieldset — names of attributes and/or relationships to include, as " \
"an array or a comma-separated string. Omit to return every field. Include " \
"\"id\" if you need it. Unknown names are rejected; see the resource's " \
"`resource_schema` for valid attribute and relationship names."
}
},
required: %w[resource id]
)

def self.call(server_context:, resource: nil, id: nil, account_id: nil, **_args)
def self.call(server_context:, resource: nil, id: nil, account_id: nil, fields: nil, **_args)
config = config_from(server_context)
# Resolve the resource FIRST so its effective required scope is known before
# the scope check (and so an unknown resource is a clean tool error).
descriptor = resolve_descriptor(resource, config)
required_scope = config.registry.required_scope_for(descriptor)
with_account(server_context, account_id:, required_scope:) do |scope_root|
McpToolkit::GetExecutor.call(resource: descriptor, scope_root:, id:)
McpToolkit::GetExecutor.call(resource: descriptor, scope_root:, id:, fields:)
end
rescue McpToolkit::Errors::InvalidParams => e
error_response("Invalid request: #{e.message}")
Expand Down
Loading
Loading