mcpspan 0.2.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 996561d4546232205752457ea56cb9aa1570402a5f056b9578db0a6bc3a590d3
4
- data.tar.gz: 846bdb5f3f05efac26377a3a52de6c0319c05acafa7b3e5a33cc808b63973590
3
+ metadata.gz: 8d0a4e3f16e81ed07733683b6bd1984d05955ba6cd3baff32dfeae0855dd3a52
4
+ data.tar.gz: 5b3a34b8f738da547c0d6cebbfdcb4debf6622eaf92f5c6bfb3ba6c68438dd5a
5
5
  SHA512:
6
- metadata.gz: 7295717445bbd5a6c57938769b3ebcb9d0185baf9d99f5f266387f900c7b86aa4881eadc31029232c6a754e4ca76b9419132524641b5c4d8c05ab9ddcbe6508f
7
- data.tar.gz: 7758b51df47a9aa9c6956477dac680cf083f95a756f961c15116b316e7a17a1a35237daa954d151bdaf9cb0d502a15edd17c7127ef73fa8fc9f3912c0c030bd9
6
+ metadata.gz: 4b93798f1e34fec652bb47d9a184d22df8a1cf6ee64b3ef774203572b6ffe9d314e511f164bb175e6f50cee52602415aa7740c46cb74525646acfbb9b34dfa1d
7
+ data.tar.gz: 8847e3f04f30ba0e3c2f2aafb693851084f963c308e3cac81c665f816f3399e591d893a0d0716e0412cfb2d77b3210902ba24dc2cc658eff8062967dbbde4863
@@ -139,6 +139,8 @@ module McpSpan
139
139
  client_version: Text.version(call.client_version),
140
140
  server_version: Text.version(call.server_version),
141
141
  response_bytes: response_bytes(response),
142
+ # A tool the server has, refused arguments included: often the schema is why.
143
+ definition_hash: call.kind.nil? && source != Source::UNKNOWN_TOOL ? Definitions.of(call.tool_name) : nil,
142
144
  timestamp: call.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%LZ"),
143
145
  session_id: call.session_id,
144
146
  parameters: call.parameters,
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module McpSpan
7
+ # Tool definitions as the server lists them, fingerprinted (contract, 3.8). Rewording a description can change how
8
+ # agents use a tool more than a change to its code; the fingerprint is taken from the answer to `tools/list`, what
9
+ # an agent actually read, and sent with every call to the tool. Kept for the process: one process reports to one
10
+ # server.
11
+ module Definitions
12
+ HASHED = %w[name title description inputSchema].freeze
13
+ ESCAPES = { '"' => '\\"', "\\" => "\\\\", "\b" => "\\b", "\f" => "\\f", "\n" => "\\n", "\r" => "\\r",
14
+ "\t" => "\\t", }.freeze
15
+
16
+ @listed = {}
17
+ @lock = Mutex.new
18
+
19
+ class << self
20
+ # The latest fingerprint listed for a tool, or nil when no listing in this process named it.
21
+ def of(tool_name)
22
+ @lock.synchronize { @listed[tool_name] }
23
+ end
24
+
25
+ # Notes every tool in a listing, in whatever key style the gem keeps them. Never raises.
26
+ def note(tools)
27
+ JSON.parse(JSON.generate(Array(tools))).each do |tool|
28
+ next unless tool.is_a?(Hash) && tool["name"].is_a?(String)
29
+
30
+ fingerprint = hash(tool)
31
+ @lock.synchronize { @listed[tool["name"]] = fingerprint } if fingerprint
32
+ end
33
+ rescue StandardError
34
+ nil
35
+ end
36
+
37
+ # For tests: forgets every listing.
38
+ def forget
39
+ @lock.synchronize { @listed.clear }
40
+ end
41
+
42
+ # The first 16 hex characters of the SHA-256 of the tool's name, title, description and input schema, as
43
+ # canonical JSON; nil for a definition that cannot be written so.
44
+ def hash(tool)
45
+ hashed = HASHED.each_with_object({}) { |field, out| out[field] = tool[field] unless tool[field].nil? }
46
+ Digest::SHA256.hexdigest(canonical(hashed))[0, 16]
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ private
52
+
53
+ # Sorted keys, no whitespace, minimal escaping: the same text in every SDK.
54
+ def canonical(value)
55
+ case value
56
+ when nil then "null"
57
+ when true then "true"
58
+ when false then "false"
59
+ when Integer then value.to_s
60
+ when Float
61
+ raise ArgumentError, "not a JSON number" unless value.finite?
62
+
63
+ value == value.floor && value.abs < 1e15 ? value.to_i.to_s : value.to_s
64
+ when String then text(value)
65
+ when Array then "[#{value.map { |item| canonical(item) }.join(",")}]"
66
+ when Hash
67
+ pairs = value.map { |key, item| [key.to_s, item] }.sort_by(&:first)
68
+ "{#{pairs.map { |key, item| "#{text(key)}:#{canonical(item)}" }.join(",")}}"
69
+ else raise ArgumentError, "cannot fingerprint #{value.class}"
70
+ end
71
+ end
72
+
73
+ def text(value)
74
+ out = +'"'
75
+ value.each_char do |character|
76
+ out << (ESCAPES[character] || (character.ord < 0x20 ? format("\\u%04x", character.ord) : character))
77
+ end
78
+ out << '"'
79
+ end
80
+ end
81
+ end
82
+ end
data/lib/mcpspan/event.rb CHANGED
@@ -6,8 +6,8 @@ module McpSpan
6
6
  # One call of a tool, a resource or a prompt, in the shape the ingest API takes. Parameter values are never in it.
7
7
  Event = Struct.new(
8
8
  :id, :kind, :tool_name, :duration_ms, :success, :error_source, :error_type, :error_message,
9
- :client_type, :client_name, :client_version, :server_version, :response_bytes, :timestamp, :session_id,
10
- :parameters,
9
+ :client_type, :client_name, :client_version, :server_version, :response_bytes, :definition_hash, :timestamp,
10
+ :session_id, :parameters,
11
11
  keyword_init: true,
12
12
  ) do
13
13
  # The event as the API takes it, leaving absent fields out rather than sending them as null.
@@ -26,6 +26,7 @@ module McpSpan
26
26
  clientVersion: client_version,
27
27
  serverVersion: server_version,
28
28
  responseBytes: response_bytes,
29
+ definitionHash: definition_hash,
29
30
  timestamp: timestamp,
30
31
  sdkVersion: VERSION,
31
32
  sessionId: session_id,
@@ -39,9 +39,35 @@ module McpSpan
39
39
 
40
40
  server.singleton_class.prepend(ServerHooks)
41
41
  server.singleton_class.prepend(Primitives::ServerHooks) if Primitives.hookable?
42
+ watch_listings(server)
42
43
  server
43
44
  end
44
45
 
46
+ # Notes the tools each `tools/list` answer describes (contract, 3.8). The gem binds its handlers when the server
47
+ # is built, so the bound handler is wrapped where the server keeps it, taking the server's context only if the
48
+ # handler it wraps asks for it, as the gem decides from the handler's own parameters.
49
+ def watch_listings(server)
50
+ handlers = server.instance_variable_get(:@handlers)
51
+ method = ::MCP::Methods::TOOLS_LIST
52
+ original = handlers.is_a?(Hash) ? handlers[method] : nil
53
+ return unless original.respond_to?(:call) && original.respond_to?(:parameters)
54
+
55
+ note = lambda do |result|
56
+ Definitions.note(result[:tools] || result["tools"]) if Collector.collecting? && result.is_a?(Hash)
57
+ result
58
+ end
59
+ contextual = original.parameters.any? { |kind, name| %i[key keyreq].include?(kind) && name == :server_context }
60
+ handlers[method] = if contextual
61
+ lambda do |params, server_context: nil|
62
+ note.call(original.call(params, server_context: server_context))
63
+ end
64
+ else
65
+ ->(params) { note.call(original.call(params)) }
66
+ end
67
+ rescue StandardError
68
+ nil
69
+ end
70
+
45
71
  def hookable?
46
72
  HOOKED.all? { |name| ::MCP::Server.private_method_defined?(name) }
47
73
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module McpSpan
4
4
  # The SDK's own version, reported with every event.
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
data/lib/mcpspan.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "mcpspan/event"
6
6
  require_relative "mcpspan/transport"
7
7
  require_relative "mcpspan/reporter"
8
8
  require_relative "mcpspan/collector"
9
+ require_relative "mcpspan/definitions"
9
10
  require_relative "mcpspan/instrumentation"
10
11
  require_relative "mcpspan/primitives"
11
12
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcpspan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kacper Zatoń
@@ -20,6 +20,7 @@ files:
20
20
  - README.md
21
21
  - lib/mcpspan.rb
22
22
  - lib/mcpspan/collector.rb
23
+ - lib/mcpspan/definitions.rb
23
24
  - lib/mcpspan/event.rb
24
25
  - lib/mcpspan/instrumentation.rb
25
26
  - lib/mcpspan/primitives.rb