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 +4 -4
- data/lib/mcpspan/collector.rb +2 -0
- data/lib/mcpspan/definitions.rb +82 -0
- data/lib/mcpspan/event.rb +3 -2
- data/lib/mcpspan/instrumentation.rb +26 -0
- data/lib/mcpspan/version.rb +1 -1
- data/lib/mcpspan.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d0a4e3f16e81ed07733683b6bd1984d05955ba6cd3baff32dfeae0855dd3a52
|
|
4
|
+
data.tar.gz: 5b3a34b8f738da547c0d6cebbfdcb4debf6622eaf92f5c6bfb3ba6c68438dd5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4b93798f1e34fec652bb47d9a184d22df8a1cf6ee64b3ef774203572b6ffe9d314e511f164bb175e6f50cee52602415aa7740c46cb74525646acfbb9b34dfa1d
|
|
7
|
+
data.tar.gz: 8847e3f04f30ba0e3c2f2aafb693851084f963c308e3cac81c665f816f3399e591d893a0d0716e0412cfb2d77b3210902ba24dc2cc658eff8062967dbbde4863
|
data/lib/mcpspan/collector.rb
CHANGED
|
@@ -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, :
|
|
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
|
data/lib/mcpspan/version.rb
CHANGED
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.
|
|
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
|