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/align-mcp-with-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ruby": minor
---

A `tools/call` that names a tool the server never registered now sends `$mcp_unknown_tool` instead of a failed `$mcp_tool_call`; the client gets the same error as before. A call that echoes a conversation handle keeps it on the event.
4 changes: 3 additions & 1 deletion lib/posthog/mcp/constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ module EventType
MCP_RESOURCES_READ = 'mcp:resources/read'
MCP_TOOLS_CALL = 'mcp:tools/call'
MCP_TOOLS_LIST = 'mcp:tools/list'
MCP_UNKNOWN_TOOL = 'mcp:unknown_tool'

EVENT_NAME_BY_TYPE = {
CUSTOM => Event::CUSTOM,
Expand All @@ -100,7 +101,8 @@ module EventType
MCP_RESOURCES_LIST => Event::RESOURCES_LIST,
MCP_RESOURCES_READ => Event::RESOURCE_READ,
MCP_TOOLS_CALL => Event::TOOL_CALL,
MCP_TOOLS_LIST => Event::TOOLS_LIST
MCP_TOOLS_LIST => Event::TOOLS_LIST,
MCP_UNKNOWN_TOOL => '$mcp_unknown_tool'
}.freeze
end
end
Expand Down
6 changes: 5 additions & 1 deletion lib/posthog/mcp/event_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,14 @@ def tool_call?(event)
event['event_type'] == EventType::MCP_TOOLS_CALL
end

def unknown_tool?(event)
event['event_type'] == EventType::MCP_UNKNOWN_TOOL
end

def add_common_properties(event, properties)
if present(event['resource_name'])
properties[Property::RESOURCE_NAME] = event['resource_name']
properties[Property::TOOL_NAME] = event['resource_name'] if tool_call?(event)
properties[Property::TOOL_NAME] = event['resource_name'] if tool_call?(event) || unknown_tool?(event)
end
if present(event['tool_description']) && tool_call?(event)
properties[Property::TOOL_DESCRIPTION] =
Expand Down
27 changes: 27 additions & 0 deletions lib/posthog/mcp/instrumentation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,15 @@ def dispatch_tool_call
return result
end

if unregistered_tool?(name)
begin
result = yield
ensure
safely { record_unknown_tool(name, request, minted ? nil : conversation_id) }
end
return result
end

safely { prime_session(request, minted ? nil : conversation_id) }
begin
result = yield
Expand Down Expand Up @@ -336,6 +345,15 @@ def record_missing_capability(name, arguments, request)
finish_event(event, request)
end

def record_unknown_tool(name, request, conversation_id)
session_id = prepare_request(request, conversation_id: conversation_id)
event = base_event(EventType::MCP_UNKNOWN_TOOL, session_id, request)
event['conversation_id'] = conversation_id
event.delete('duration')
event['resource_name'] = Sanitization.redact_pii(Sanitization.sanitize_captured_value(name))
finish_event(event, request)
end

# The virtual tool declares `llm_model` itself, so the argument is never
# stripped and is read straight off the call.
def self_reported_model(arguments)
Expand Down Expand Up @@ -605,6 +623,15 @@ def virtual_tool?(name)
tools.is_a?(Hash) && tools[name].equal?(@data.virtual_tool)
end

# True when the server's registry is readable and has no tool by this name.
# A server whose registry cannot be read stays a failed call.
def unregistered_tool?(name)
return false unless name.is_a?(String) && !name.empty?

tools = @server.respond_to?(:tools) ? @server.tools : nil
tools.is_a?(Hash) && !tools.key?(name)
end

# Injected argument names the analytics layer owns for this tool: the ones
# it injected at tools/list, or (never listed) the ones the tool's own
# schema does not declare. A composed or referenced schema is never
Expand Down
53 changes: 53 additions & 0 deletions spec/posthog/mcp/instrument_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,13 @@ def stringify_result_keys(target)
expect(events.none? { |e| e[:event] == '$mcp_tool_call' }).to be(true)
end

it 'advertises get_more_tools with annotations that say it records data' do
described_class.instrument(server, client, report_missing: true)
virtual = server.handle(rpc(1, 'tools/list'))[:result][:tools].find { |t| t[:name] == 'get_more_tools' }
expect(virtual[:annotations]).to include(readOnlyHint: false, destructiveHint: false,
idempotentHint: false, openWorldHint: false)
end

it 'lets the gem validate get_more_tools arguments and still records the missing capability' do
described_class.instrument(server, client, report_missing: true)
result = server.handle(rpc(2, 'tools/call', { name: 'get_more_tools', arguments: {} }))
Expand Down Expand Up @@ -763,6 +770,52 @@ def stringify_result_keys(target)
'$mcp_llm_model_source' => 'self_reported')
end

context 'when the call names a tool the server never registered' do
let(:unknown_call) { rpc(2, 'tools/call', { name: 'no_such_tool', arguments: { q: 'x' } }) }

it 'records $mcp_unknown_tool instead of a failed call and answers the client as before' do
baseline = MCP::Server.new(name: 'spec-server', version: '9.9.9', tools: tools).handle(unknown_call)
described_class.instrument(server, client)
expect(server.handle(unknown_call)).to eq(baseline)
events = drain_events(client)
unknown = events.select { |e| e[:event] == '$mcp_unknown_tool' }
expect(unknown.size).to eq(1)
expect(unknown[0][:properties]).to include('$mcp_tool_name' => 'no_such_tool')
expect(unknown[0][:properties]['$session_id']).to be_a(String)
expect(events.map { |e| e[:event] }).to eq(['$mcp_unknown_tool'])
end

it 'keeps the call in the conversation whose handle it echoes, without emitting $mcp_initialize' do
described_class.instrument(server, client, enable_conversation_id: true)
first = server.handle(rpc(1, 'tools/call', { name: 'echo', arguments: { message: 'hi', context: 'c' } }))
handle = JSON.parse(first[:result][:content][1][:text])['conversation_id']
drain_events(client)

arguments = { q: 'x', conversation_id: handle.upcase }
server.handle(rpc(2, 'tools/call', { name: 'no_such_tool', arguments: arguments }))
events = drain_events(client)
expect(events.map { |e| e[:event] }).to eq(['$mcp_unknown_tool'])
expect(events[0][:properties]).to include(
'$mcp_conversation_id' => handle,
'$session_id' => PostHog::MCP.derive_session_id_from_conversation(handle)
)
end

it 'does not stamp a conversation id on an unknown tool call that echoes none' do
described_class.instrument(server, client, enable_conversation_id: true)
server.handle(unknown_call)
expect(drain_events(client)[0][:properties]).not_to have_key('$mcp_conversation_id')
end

it 'still records a failed call for a registered tool given invalid arguments' do
described_class.instrument(server, client)
server.handle(rpc(2, 'tools/call', { name: 'echo', arguments: {} }))
events = drain_events(client)
expect(events.map { |e| e[:event] }).to include('$mcp_tool_call')
expect(events.none? { |e| e[:event] == '$mcp_unknown_tool' }).to be(true)
end
end

it 'captures llm_model from the injected argument or client metadata by default' do
described_class.instrument(server, client)
server.handle(rpc(1, 'tools/call',
Expand Down
Loading