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
5 changes: 5 additions & 0 deletions .changeset/lean-tools-list-envelope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-ruby': patch
---

Stop copying the tool descriptors into `$mcp_response` on `$mcp_tools_list` events. The response keeps only the envelope (`nextCursor`, `ttlMs`, and so on) and the tool names stay in `$mcp_listed_tool_names`.
10 changes: 9 additions & 1 deletion lib/posthog/mcp/instrumentation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,19 @@ def dispatch_tools_list

safely do
session_id = prepare_request(@request)
record_tools_list(session_id, names: names, response: result, empty: empty)
record_tools_list(session_id, names: names, response: tools_list_envelope(result), empty: empty)
end
result
end

# `resultType` is a lifecycle discriminator the gem stamps on every modern-wire reply, not envelope data.
def tools_list_envelope(result)
return nil unless result.is_a?(Hash)

envelope = result.reject { |key, value| %w[tools resultType].include?(key.to_s) || value.nil? }
envelope unless envelope.empty?
end

def dispatch_initialize
client_info = fetch(@params, :clientInfo)
client_name = client_info.is_a?(Hash) ? fetch(client_info, :name) : nil
Expand Down
39 changes: 39 additions & 0 deletions spec/posthog/mcp/instrument_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,45 @@ def initialize_request(id = 1, version = '2025-06-18')
expect(listing['$mcp_error_message']).to eq('tools/list returned no tools')
expect(events.map { |e| e[:event] }).to include('$exception')
end

describe 'tools/list response capture' do
let(:names) { %w[echo boom owns_context structured soft_fail] }
let(:modern_meta) do
{ _meta: { 'io.modelcontextprotocol/protocolVersion' => '2026-07-28',
'io.modelcontextprotocol/clientCapabilities' => {} } }
end

def stringify_result_keys(target)
target.configuration = MCP::Configuration.new(around_request: lambda { |_data, &handler|
handler.call.transform_keys(&:to_s)
})
end

[
Comment thread
lucasheriques marked this conversation as resolved.
{ label: 'cache hints, symbol keys', options: { ttl_ms: 5000, cache_scope: 'public' },
response: { 'ttlMs' => 5000, 'cacheScope' => 'public' } },
{ label: 'cache hints, string keys', options: { ttl_ms: 5000, cache_scope: 'public' }, string_keys: true,
response: { 'ttlMs' => 5000, 'cacheScope' => 'public' } },
{ label: 'only tools', response: nil },
{ label: 'first page', options: { page_size: 2 }, listed: 2, response: :next_cursor },
{ label: 'modern wire, gem defaults', modern: true, response: { 'ttlMs' => 0, 'cacheScope' => 'private' } }
].each do |row|
it "records only the envelope: #{row[:label]}" do
list_server = MCP::Server.new(name: 'spec-server', tools: tools, **row.fetch(:options, {}))
stringify_result_keys(list_server) if row[:string_keys]
described_class.instrument(list_server, client)
result = list_server.handle(rpc(1, 'tools/list', row[:modern] ? modern_meta : nil))[:result]
listed = names.first(row.fetch(:listed, names.length))
expected = row[:response] == :next_cursor ? { 'nextCursor' => result[:nextCursor] } : row[:response]

properties = events_named(drain_events(client), '$mcp_tools_list').first[:properties]
expect(properties['$mcp_response']).to eq(expected)
expect(properties.key?('$mcp_response')).to eq(!expected.nil?)
expect(properties['$mcp_listed_tool_names']).to eq(listed)
expect((result[:tools] || result['tools']).map { |tool| tool[:name] || tool['name'] }).to eq(listed)
end
end
end
end

describe 'identify, event_properties and before_send' do
Expand Down
Loading