From a00de887b2173d9665acc35822728052129fb69f Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Thu, 2 Jul 2026 17:25:41 +0200 Subject: [PATCH] Add sparse fieldsets (fields) to get/list tools Add an optional `fields` argument to the `get` and `list` tools (JSON:API sparse fieldsets): return only the named attributes and/or relationships, shrinking the MCP response. Attribute and relationship names share one flat namespace; unknown names are rejected with InvalidParams. - Serializer::Base honors `fields:` natively (serialize_one/collection/ serializable_hash); unselected has_many links are never queried, and the links block is omitted when no relationship is selected. - FieldSelection parses/validates the request; Serialization bridges the executors to the serializer, pruning output for injected serializers that predate the `fields:` kwarg so the injection contract is unchanged. - Omitting `fields` returns the full shape exactly as before (backward compatible). Bumps version 0.1.0 -> 0.2.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 22 ++++ README.md | 7 ++ lib/mcp_toolkit/field_selection.rb | 93 ++++++++++++++ lib/mcp_toolkit/get_executor.rb | 13 +- lib/mcp_toolkit/list_executor.rb | 7 +- lib/mcp_toolkit/serialization.rb | 62 ++++++++++ lib/mcp_toolkit/serializer/base.rb | 50 ++++++-- lib/mcp_toolkit/tools/get.rb | 16 ++- lib/mcp_toolkit/tools/list.rb | 17 ++- lib/mcp_toolkit/tools/resource_schema.rb | 3 +- lib/mcp_toolkit/version.rb | 2 +- spec/mcp_toolkit/field_selection_spec.rb | 114 ++++++++++++++++++ .../registry_and_executors_spec.rb | 78 ++++++++++++ spec/mcp_toolkit/serializer/base_spec.rb | 43 +++++++ spec/mcp_toolkit/server_end_to_end_spec.rb | 16 +++ 15 files changed, 523 insertions(+), 20 deletions(-) create mode 100644 lib/mcp_toolkit/field_selection.rb create mode 100644 lib/mcp_toolkit/serialization.rb create mode 100644 spec/mcp_toolkit/field_selection_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b96fb1..e525ecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index fe1281a..31f1a8d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/mcp_toolkit/field_selection.rb b/lib/mcp_toolkit/field_selection.rb new file mode 100644 index 0000000..84e7465 --- /dev/null +++ b/lib/mcp_toolkit/field_selection.rb @@ -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 diff --git a/lib/mcp_toolkit/get_executor.rb b/lib/mcp_toolkit/get_executor.rb index 03d044f..c1dcf01 100644 --- a/lib/mcp_toolkit/get_executor.rb +++ b/lib/mcp_toolkit/get_executor.rb @@ -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 diff --git a/lib/mcp_toolkit/list_executor.rb b/lib/mcp_toolkit/list_executor.rb index 871e68e..3de1a28 100644 --- a/lib/mcp_toolkit/list_executor.rb +++ b/lib/mcp_toolkit/list_executor.rb @@ -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 `{ => [...], 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 @@ -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 diff --git a/lib/mcp_toolkit/serialization.rb b/lib/mcp_toolkit/serialization.rb new file mode 100644 index 0000000..3f6903d --- /dev/null +++ b/lib/mcp_toolkit/serialization.rb @@ -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 diff --git a/lib/mcp_toolkit/serializer/base.rb b/lib/mcp_toolkit/serializer/base.rb index df11ac8..29e15aa 100644 --- a/lib/mcp_toolkit/serializer/base.rb +++ b/lib/mcp_toolkit/serializer/base.rb @@ -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. # @@ -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: } @@ -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 @@ -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 diff --git a/lib/mcp_toolkit/tools/get.rb b/lib/mcp_toolkit/tools/get.rb index addd0a9..5636721 100644 --- a/lib/mcp_toolkit/tools/get.rb +++ b/lib/mcp_toolkit/tools/get.rb @@ -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( @@ -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}") diff --git a/lib/mcp_toolkit/tools/list.rb b/lib/mcp_toolkit/tools/list.rb index c77e08b..e4468ae 100644 --- a/lib/mcp_toolkit/tools/list.rb +++ b/lib/mcp_toolkit/tools/list.rb @@ -21,6 +21,13 @@ class McpToolkit::Tools::List < McpToolkit::Tools::Base available filter keys via `resource_schema` (the `filters` array). Unknown keys are rejected. + Sparse fieldset: + - fields: names of the attributes and/or relationships to include in each record, as an + array or a comma-separated string. Omit to return every field. Narrowing the set shrinks + the response (and skips loading unselected relationships) — prefer it when you only need a + few fields. Include "id" if you need it. Valid names come from a resource's + `resource_schema` (its `attributes` and `relationships`); unknown names are rejected. + For tokens that span multiple accounts (superuser), pass `account_id` to pin the active account; account-scoped tokens may omit it. The response shape is { "": [...], "meta": { total_count, limit, offset } }. @@ -48,7 +55,15 @@ class McpToolkit::Tools::List < McpToolkit::Tools::Base additionalProperties: true }, limit: { type: "integer", description: "Page size (default 25, max 100)" }, - offset: { type: "integer", description: "Pagination offset (default 0)" } + offset: { type: "integer", description: "Pagination offset (default 0)" }, + fields: { + type: %w[array string], + items: { type: "string" }, + description: "Sparse fieldset — names of attributes and/or relationships to include in " \ + "each record, as an array or a comma-separated string. Omit to return every " \ + "field. Include \"id\" if you need it. Unknown names are rejected; see a " \ + "resource's `resource_schema` for valid attribute and relationship names." + } }, required: ["resource"] ) diff --git a/lib/mcp_toolkit/tools/resource_schema.rb b/lib/mcp_toolkit/tools/resource_schema.rb index 16f16eb..6350620 100644 --- a/lib/mcp_toolkit/tools/resource_schema.rb +++ b/lib/mcp_toolkit/tools/resource_schema.rb @@ -11,7 +11,8 @@ class McpToolkit::Tools::ResourceSchema < McpToolkit::Tools::Base - relationships: associated resources emitted in the record's `links` - standard_filters: ids, updated_since, limit, offset (accepted by the `list` tool) - filters: the per-attribute equality filter keys the `list` tool accepts - Call this before `list` to learn a resource's shape. + The `attributes` and `relationships` names are also the valid values for the `fields` sparse + fieldset argument on `get` / `list`. Call this before `list` to learn a resource's shape. DESC input_schema( diff --git a/lib/mcp_toolkit/version.rb b/lib/mcp_toolkit/version.rb index e129aa4..e713196 100644 --- a/lib/mcp_toolkit/version.rb +++ b/lib/mcp_toolkit/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module McpToolkit - VERSION = "0.1.0" + VERSION = "0.2.0" end diff --git a/spec/mcp_toolkit/field_selection_spec.rb b/spec/mcp_toolkit/field_selection_spec.rb new file mode 100644 index 0000000..209a837 --- /dev/null +++ b/spec/mcp_toolkit/field_selection_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe McpToolkit::FieldSelection do + # A Base-derived serializer that can describe its members, so validation is + # exercised. `line_items` is aliased to the link key `items` to prove selection + # matches on the LINK KEY, not the association name. + let(:serializer) do + Class.new(McpToolkit::Serializer::Base) do + attributes :id, :name, :total + has_one :account + has_many :line_items, key: :items + self.model_class = Class.new { def self.model_name = FakeModelName.new("things") } + + def self.name = "ThingSerializer" + end + end + + # A minimal resource wired to that serializer (FieldSelection only reads the + # serializer's declared members off it). + let(:resource) { McpToolkit::Resource.new(:things).tap { |r| r.serializer(serializer) } } + + describe ".build" do + it "returns nil when nothing is requested" do + expect(described_class.build(resource:, raw: nil)).to be_nil + expect(described_class.build(resource:, raw: "")).to be_nil + expect(described_class.build(resource:, raw: " ")).to be_nil + expect(described_class.build(resource:, raw: [])).to be_nil + end + + it "parses a comma-separated string into de-duplicated, trimmed symbols" do + selection = described_class.build(resource:, raw: " id , name ,id") + + expect(selection.names).to eq(%i[id name]) + end + + it "parses an array of names into symbols" do + selection = described_class.build(resource:, raw: %w[id name]) + + expect(selection.names).to eq(%i[id name]) + end + + it "accepts a relationship link key (not just attributes)" do + selection = described_class.build(resource:, raw: "id,items") + + expect(selection.names).to eq(%i[id items]) + end + + it "rejects an unknown field with an actionable InvalidParams listing the selectable set" do + expect do + described_class.build(resource:, raw: "id,bogus") + end.to raise_error( + McpToolkit::Errors::InvalidParams, + "unknown field(s): bogus. Selectable fields for this resource: account, id, items, name, total" + ) + end + + it "skips validation for a serializer that cannot describe its members (opaque injection)" do + opaque = Class.new do + def self.serialize_one(_record, scope: nil); end + def self.serialize_collection(_records, scope: nil, total_count: nil, limit: nil, offset: nil); end + end + opaque_resource = McpToolkit::Resource.new(:opaque).tap { |r| r.serializer(opaque) } + + selection = described_class.build(resource: opaque_resource, raw: "anything,goes") + + expect(selection.names).to eq(%i[anything goes]) + end + end + + describe "#prune_record" do + subject(:selection) { described_class.build(resource:, raw: fields) } + + let(:full) do + { id: 7, name: "widget", total: 100, "links" => { "account" => 42, "items" => [1, 3] } } + end + + context "when only attributes are selected" do + let(:fields) { "id,name" } + + it "keeps the selected attributes and drops the links block entirely" do + expect(selection.prune_record(full)).to eq(id: 7, name: "widget") + end + end + + context "when an attribute and a relationship are selected" do + let(:fields) { "id,items" } + + it "keeps the attribute and narrows links to the selected key" do + expect(selection.prune_record(full)).to eq(id: 7, "links" => { "items" => [1, 3] }) + end + end + end + + describe "#prune_collection" do + subject(:selection) { described_class.build(resource:, raw: "id") } + + it "prunes each row and passes the meta block through untouched" do + wrapper = { + things: [ + { id: 1, name: "a", "links" => { "account" => 1 } }, + { id: 2, name: "b", "links" => { "account" => 2 } } + ], + meta: { total_count: 2, limit: 25, offset: 0 } + } + + expect(selection.prune_collection(wrapper)).to eq( + things: [{ id: 1 }, { id: 2 }], + meta: { total_count: 2, limit: 25, offset: 0 } + ) + end + end +end diff --git a/spec/mcp_toolkit/registry_and_executors_spec.rb b/spec/mcp_toolkit/registry_and_executors_spec.rb index a215e02..cbcea67 100644 --- a/spec/mcp_toolkit/registry_and_executors_spec.rb +++ b/spec/mcp_toolkit/registry_and_executors_spec.rb @@ -110,6 +110,27 @@ def self.model_name expect(result[:widgets].map { |w| w[:id] }).to eq([1, 3]) end + describe "sparse fieldsets (fields)" do + it "returns only the requested attributes when given a comma-separated string" do + result = described_class.call(resource:, scope_root: account, params: { fields: "id,name" }) + + expect(result[:widgets]).to eq([{ id: 1, name: "alpha" }, { id: 2, name: "beta" }, { id: 3, name: "gamma" }]) + expect(result[:meta]).to eq(total_count: 3, limit: 25, offset: 0) + end + + it "accepts an array of field names too" do + result = described_class.call(resource:, scope_root: account, params: { fields: %w[id] }) + + expect(result[:widgets]).to eq([{ id: 1 }, { id: 2 }, { id: 3 }]) + end + + it "rejects an unknown field with InvalidParams" do + expect do + described_class.call(resource:, scope_root: account, params: { fields: "id,bogus" }) + end.to raise_error(McpToolkit::Errors::InvalidParams, /unknown field\(s\): bogus/) + end + end + describe "operator-based (complex hash) filtering — API v3 parity" do it "applies a single { op:, value: } comparison condition" do result = described_class.call( @@ -234,6 +255,63 @@ def self.name = "ThingSerializer" described_class.call(resource:, scope_root: account, id: nil) end.to raise_error(McpToolkit::Errors::InvalidParams, /id is required/) end + + it "honors a sparse `fields` selection, returning only the requested attributes" do + result = described_class.call(resource:, scope_root: account, id: 2, fields: "id,name") + + expect(result).to eq(id: 2, name: "beta") + end + + it "rejects an unknown `fields` name before the lookup runs" do + expect do + described_class.call(resource:, scope_root: account, id: 2, fields: "bogus") + end.to raise_error(McpToolkit::Errors::InvalidParams, /unknown field\(s\): bogus/) + end + end + + # An injected serializer that predates the `fields:` kwarg (implements only the + # two-arg contract). Sparse fieldsets must still work by PRUNING its output, so + # the injection contract stays intact as the feature is added. + describe "sparse fieldsets on a serializer without a `fields:` keyword (output pruning)" do + let(:legacy_serializer) do + Class.new do + def self.serialize_one(record, scope: nil) + { id: record.id, name: record.name, "links" => { "owner" => record.id } } + end + + def self.serialize_collection(records, scope: nil, total_count: nil, limit: nil, offset: nil) + rows = records.map { |r| serialize_one(r, scope:) } + { widgets: rows, meta: { total_count: total_count || rows.size, limit:, offset: } } + end + end + end + + before do + serializer = legacy_serializer + rel = relation + McpToolkit.configure do |c| + c.registry.register(:legacy_widgets) do + model Object + serializer serializer + scope { |_root| rel } + end + end + end + + subject(:legacy_resource) { McpToolkit.registry.fetch("legacy_widgets") } + + it "prunes GetExecutor output to the requested attribute, dropping links" do + result = McpToolkit::GetExecutor.call(resource: legacy_resource, scope_root: account, id: 2, fields: "id") + + expect(result).to eq(id: 2) + end + + it "prunes ListExecutor rows while leaving the meta block untouched" do + result = McpToolkit::ListExecutor.call(resource: legacy_resource, scope_root: account, params: { fields: "id" }) + + expect(result[:widgets]).to eq([{ id: 1 }, { id: 2 }, { id: 3 }]) + expect(result[:meta]).to include(limit: 25, offset: 0) + end end describe McpToolkit::ResourceSchema do diff --git a/spec/mcp_toolkit/serializer/base_spec.rb b/spec/mcp_toolkit/serializer/base_spec.rb index 0d3a57f..81eee64 100644 --- a/spec/mcp_toolkit/serializer/base_spec.rb +++ b/spec/mcp_toolkit/serializer/base_spec.rb @@ -58,6 +58,36 @@ def self.model_name end end + describe ".serialize_one with a sparse `fields:` selection" do + let(:record) do + FakeRecord.new( + id: 7, total: 100, created_at: Time.utc(2026, 6, 18, 12, 0, 0), + synced_account_id: 42, owner_id: 5, owner_type: "User", + line_items: FakeRelation.new([FakeRecord.new(id: 3), FakeRecord.new(id: 1)]) + ) + end + + it "emits only the selected attributes and omits the links block when no relationship is selected" do + hash = serializer.serialize_one(record, fields: %i[id total]) + + expect(hash).to eq(id: 7, total: 100) + expect(hash).not_to have_key("links") + end + + it "narrows the links block to the selected relationships" do + hash = serializer.serialize_one(record, fields: %i[id account]) + + expect(hash).to eq(id: 7, "links" => { "account" => 42 }) + end + + it "returns the full shape when fields: is nil (default, unchanged behavior)" do + hash = serializer.serialize_one(record, fields: nil) + + expect(hash.keys).to include(:id, :total, :created_at, "links") + expect(hash["links"].keys).to eq(%w[account line_items owner]) + end + end + describe ".serialize_collection" do it "wraps rows under the plural root with a meta block" do records = [FakeRecord.new( @@ -72,6 +102,19 @@ def self.model_name expect(result[:orders].size).to eq(1) expect(result[:meta]).to eq(total_count: 50, limit: 25, offset: 0) end + + it "applies a sparse `fields:` selection to every row, leaving the meta block intact" do + records = [FakeRecord.new( + id: 1, total: 10, created_at: nil, + synced_account_id: 1, owner_id: nil, owner_type: nil, + line_items: FakeRelation.new([]) + )] + + result = serializer.serialize_collection(records, total_count: 50, limit: 25, offset: 0, fields: %i[id]) + + expect(result[:orders]).to eq([{ id: 1 }]) + expect(result[:meta]).to eq(total_count: 50, limit: 25, offset: 0) + end end describe "injection: a custom serializer satisfying only the contract" do diff --git a/spec/mcp_toolkit/server_end_to_end_spec.rb b/spec/mcp_toolkit/server_end_to_end_spec.rb index 15278c5..b4ef33b 100644 --- a/spec/mcp_toolkit/server_end_to_end_spec.rb +++ b/spec/mcp_toolkit/server_end_to_end_spec.rb @@ -86,6 +86,22 @@ def call_tool(arguments, bearer: "forwarded-token") expect(payload["meta"]).to include("total_count" => 2) end + it "honors a sparse `fields` argument end-to-end, returning only the requested attributes" do + response = call_tool({ "resource" => "widgets", "fields" => "id" }) + + expect(response.dig("result", "isError")).to be_falsey + payload = JSON.parse(response.dig("result", "content", 0, "text")) + expect(payload["widgets"]).to eq([{ "id" => 1 }, { "id" => 2 }]) + expect(payload["meta"]).to include("total_count" => 2) + end + + it "returns a clean tool error for an unknown `fields` name" do + response = call_tool({ "resource" => "widgets", "fields" => "bogus" }) + + expect(response.dig("result", "isError")).to be(true) + expect(response.dig("result", "content", 0, "text")).to match(/unknown field\(s\): bogus/) + end + it "allows a list call when the token carries the required __read scope" do # the default before-stub already carries scopes: ["widgets_app__read"] expect(list_response.dig("result", "isError")).to be_falsey