mcpspan 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +232 -0
- data/lib/mcpspan/collector.rb +211 -0
- data/lib/mcpspan/event.rb +49 -0
- data/lib/mcpspan/instrumentation.rb +216 -0
- data/lib/mcpspan/primitives.rb +179 -0
- data/lib/mcpspan/reporter.rb +224 -0
- data/lib/mcpspan/text.rb +72 -0
- data/lib/mcpspan/transport.rb +64 -0
- data/lib/mcpspan/version.rb +6 -0
- data/lib/mcpspan.rb +88 -0
- metadata +55 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module McpSpan
|
|
6
|
+
# Measuring the tools of a server built on the official Ruby MCP SDK, the `mcp` gem.
|
|
7
|
+
#
|
|
8
|
+
# The gem has one `around_request` slot, which belongs to the developer, and it sees neither a call's arguments
|
|
9
|
+
# nor its result. So an instrumented server gets this module prepended to its own singleton class: `call_tool`
|
|
10
|
+
# sees the whole call, and `call_tool_with_args`, which the gem calls only once the arguments have passed its
|
|
11
|
+
# checks, shows directly whether the call reached the tool. Other servers, and the gem's classes, are untouched.
|
|
12
|
+
module Instrumentation
|
|
13
|
+
# Set, fiber-locally, while an instrumented server handles a call, so a tracked tool inside it counts nothing
|
|
14
|
+
# twice and the server can see that the tool was reached.
|
|
15
|
+
CURRENT = :__mcpspan_call
|
|
16
|
+
|
|
17
|
+
# What an instrumented server knows about the call it is handling.
|
|
18
|
+
State = Struct.new(:reached)
|
|
19
|
+
|
|
20
|
+
# Named, not referenced: the gem autoloads these, and loading the HTTP transport needs the rack gem, which a stdio
|
|
21
|
+
# server need not have. Touching the constant there would raise LoadError inside a tool call.
|
|
22
|
+
HTTP_TRANSPORT = "MCP::Server::Transports::StreamableHTTPTransport"
|
|
23
|
+
INPUT_REQUIRED = "MCP::InputRequiredResult"
|
|
24
|
+
|
|
25
|
+
# What a hook rescues so that measuring can never be why a call fails: StandardError, and ScriptError for a
|
|
26
|
+
# library that could not be loaded. Signals and exits pass through.
|
|
27
|
+
INTERNAL = [StandardError, ScriptError].freeze
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# The gem's private methods the hooks take the place of. If a version renames them, instrumenting leaves the
|
|
32
|
+
# server as it was rather than guessing; test/instrument_test.rb fails first.
|
|
33
|
+
HOOKED = %i[call_tool call_tool_with_args].freeze
|
|
34
|
+
|
|
35
|
+
def instrument(server)
|
|
36
|
+
return server unless defined?(::MCP::Server) && server.is_a?(::MCP::Server)
|
|
37
|
+
return server if server.singleton_class.include?(ServerHooks)
|
|
38
|
+
return server unless hookable?
|
|
39
|
+
|
|
40
|
+
server.singleton_class.prepend(ServerHooks)
|
|
41
|
+
server.singleton_class.prepend(Primitives::ServerHooks) if Primitives.hookable?
|
|
42
|
+
server
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def hookable?
|
|
46
|
+
HOOKED.all? { |name| ::MCP::Server.private_method_defined?(name) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def excluded?(tool, name)
|
|
50
|
+
McpSpan.excluded_names.include?(name.to_s) || tool&.instance_variable_get(:@__mcpspan_excluded)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Our identifier for the connection a call arrived on, or nil for none.
|
|
54
|
+
#
|
|
55
|
+
# Over HTTP without a transport session (stateless, and every call on 2026-07-28, which has no sessions and for
|
|
56
|
+
# which the gem makes a throwaway session per request) there is none: each call would otherwise be a session of
|
|
57
|
+
# its own. The identifier is random, kept on the gem's session object, and never derived from the transport's.
|
|
58
|
+
def session_id(session)
|
|
59
|
+
return nil if session.nil?
|
|
60
|
+
|
|
61
|
+
transport = session.instance_variable_get(:@transport)
|
|
62
|
+
over_http = named?(transport, HTTP_TRANSPORT)
|
|
63
|
+
return nil if over_http && (session.session_id.nil? || session.era == :modern)
|
|
64
|
+
|
|
65
|
+
session.instance_variable_get(:@__mcpspan_session) ||
|
|
66
|
+
session.instance_variable_set(:@__mcpspan_session, SecureRandom.uuid)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The client's name and version: from the call itself on 2026-07-28, else from its connection's handshake.
|
|
70
|
+
def client(envelope, session, server)
|
|
71
|
+
info = envelope&.client_info || session&.client || server.instance_variable_get(:@client)
|
|
72
|
+
return {} unless info.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
name = info[:name] || info["name"]
|
|
75
|
+
version = info[:version] || info["version"]
|
|
76
|
+
{ client_name: name&.to_s, client_version: version&.to_s }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The version the server gives itself, `MCP::Server.new(version:)`. Unset, the gem announces its default.
|
|
80
|
+
def server_version(server)
|
|
81
|
+
server.respond_to?(:version) ? server.version&.to_s : nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def result_text(result)
|
|
85
|
+
content = result[:content] || result["content"] || []
|
|
86
|
+
texts = content.filter_map do |block|
|
|
87
|
+
next unless block.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
type = block[:type] || block["type"]
|
|
90
|
+
(block[:text] || block["text"]).to_s if type.to_s == "text"
|
|
91
|
+
end
|
|
92
|
+
Text.truncate(texts.join(" ").strip, Text::MAX_RESULT_MESSAGE)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Whether an object is of a class, or a subclass, known here only by name.
|
|
96
|
+
def named?(object, class_name)
|
|
97
|
+
object.class.ancestors.any? { |ancestor| ancestor.name == class_name }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def interim?(result)
|
|
101
|
+
named?(result, INPUT_REQUIRED)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def exception(error)
|
|
105
|
+
[error.class.name || "Exception", Text.truncate(error.message.to_s, Text::MAX_EXCEPTION_MESSAGE)]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Hooks prepended to one server's singleton class.
|
|
109
|
+
module ServerHooks
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def call_tool(request, session: nil, **rest)
|
|
113
|
+
call, tool = __mcpspan_begin(request, session, rest[:envelope])
|
|
114
|
+
return super if call.nil?
|
|
115
|
+
|
|
116
|
+
state = State.new(false)
|
|
117
|
+
outer = Thread.current[CURRENT]
|
|
118
|
+
Thread.current[CURRENT] = state
|
|
119
|
+
begin
|
|
120
|
+
result = super
|
|
121
|
+
rescue ::MCP::CancelledError
|
|
122
|
+
# A cancelled call has no outcome to record.
|
|
123
|
+
raise
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
__mcpspan_failed(call, tool, state, e)
|
|
126
|
+
raise
|
|
127
|
+
ensure
|
|
128
|
+
Thread.current[CURRENT] = outer
|
|
129
|
+
end
|
|
130
|
+
__mcpspan_settled(call, state, result)
|
|
131
|
+
result
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def call_tool_with_args(*args, **kwargs)
|
|
135
|
+
state = Thread.current[CURRENT]
|
|
136
|
+
state.reached = true if state
|
|
137
|
+
super
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def __mcpspan_begin(request, session, envelope)
|
|
141
|
+
return nil unless Collector.collecting? && request.is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
name = request[:name]
|
|
144
|
+
tool = tools[name]
|
|
145
|
+
return nil if Instrumentation.excluded?(tool, name)
|
|
146
|
+
|
|
147
|
+
call = Collector.begin_call(
|
|
148
|
+
name,
|
|
149
|
+
arguments: request[:arguments],
|
|
150
|
+
session_id: Instrumentation.session_id(session),
|
|
151
|
+
server_version: Instrumentation.server_version(self),
|
|
152
|
+
**Instrumentation.client(envelope, session, self),
|
|
153
|
+
)
|
|
154
|
+
[call, tool]
|
|
155
|
+
rescue *INTERNAL
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def __mcpspan_failed(call, tool, state, error)
|
|
160
|
+
if tool.nil?
|
|
161
|
+
Collector.record(call, success: false, source: Source::UNKNOWN_TOOL)
|
|
162
|
+
else
|
|
163
|
+
# The gem wraps what a tool raised, and keeps it; the tool's own class is what is worth recording.
|
|
164
|
+
original = error.respond_to?(:original_error) && error.original_error ? error.original_error : error
|
|
165
|
+
type, message = Instrumentation.exception(state.reached ? original : error)
|
|
166
|
+
Collector.record(call, success: false, source: Source::EXCEPTION, type: type, message: message)
|
|
167
|
+
end
|
|
168
|
+
rescue *INTERNAL
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def __mcpspan_settled(call, state, result)
|
|
173
|
+
# An interim result asking the client for input settles nothing; the call that follows it does.
|
|
174
|
+
return if Instrumentation.interim?(result)
|
|
175
|
+
return Collector.record(call, success: true) unless result.is_a?(Hash) && result[:isError]
|
|
176
|
+
|
|
177
|
+
# The gem refuses missing or invalid arguments with an error result, before the tool is called.
|
|
178
|
+
if state.reached
|
|
179
|
+
Collector.record(call, success: false, source: Source::RESULT, message: Instrumentation.result_text(result))
|
|
180
|
+
else
|
|
181
|
+
Collector.record(call, success: false, source: Source::ARGUMENTS)
|
|
182
|
+
end
|
|
183
|
+
rescue *INTERNAL
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Hooks prepended to one tracked tool class's singleton class.
|
|
189
|
+
module ToolHooks
|
|
190
|
+
def call(*args, **kwargs, &)
|
|
191
|
+
# Inside an instrumented server, the server measures the call.
|
|
192
|
+
return super if Thread.current[CURRENT] || !Collector.collecting? || Instrumentation.excluded?(self, name_value)
|
|
193
|
+
|
|
194
|
+
# Recorded by hand, the call cannot see its connection or its client.
|
|
195
|
+
call = Collector.begin_call(name_value, arguments: kwargs.except(:server_context),
|
|
196
|
+
client_name: nil, session_id: nil,)
|
|
197
|
+
begin
|
|
198
|
+
result = super
|
|
199
|
+
rescue StandardError => e
|
|
200
|
+
type, message = Instrumentation.exception(e)
|
|
201
|
+
Collector.record(call, success: false, source: Source::EXCEPTION, type: type, message: message) if call
|
|
202
|
+
raise
|
|
203
|
+
end
|
|
204
|
+
if call
|
|
205
|
+
hash = result.respond_to?(:to_h) ? result.to_h : {}
|
|
206
|
+
if hash[:isError]
|
|
207
|
+
Collector.record(call, success: false, source: Source::RESULT, message: Instrumentation.result_text(hash))
|
|
208
|
+
elsif !Instrumentation.interim?(result)
|
|
209
|
+
Collector.record(call, success: true)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
result
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module McpSpan
|
|
4
|
+
# Resource reads and prompt gets (contract, 3.5), on the official Ruby MCP SDK.
|
|
5
|
+
#
|
|
6
|
+
# The gem answers `resources/read` through `read_resource_contents`, which runs the server's read handler: its own,
|
|
7
|
+
# for resources and templates defined as classes, or one the developer set with `resources_read_handler`. It
|
|
8
|
+
# answers `prompts/get` through `get_prompt`, which refuses an unknown prompt and missing arguments before it calls
|
|
9
|
+
# `call_prompt_template_with_args`. Hooks on those three, prepended to one server's singleton class as the tool hooks
|
|
10
|
+
# are, see every read and get.
|
|
11
|
+
#
|
|
12
|
+
# What was asked for is named before anything runs, from the server's own records: a fixed resource by its URI, a
|
|
13
|
+
# templated one by its template, never by the address the client sent, and an address with neither by its scheme
|
|
14
|
+
# alone, since the rest of it came from the client.
|
|
15
|
+
module Primitives
|
|
16
|
+
# Set, fiber-locally, while an instrumented server gets a prompt, so it can see that the prompt was reached.
|
|
17
|
+
PROMPTING = :__mcpspan_prompt
|
|
18
|
+
|
|
19
|
+
SCHEME = /\A([a-zA-Z][a-zA-Z0-9+.-]*):/
|
|
20
|
+
|
|
21
|
+
# A template's `{name}` variables, as the gem matches them: one or more characters other than `/`.
|
|
22
|
+
VARIABLE = /\\\{([A-Za-z_]\w*)\\\}/
|
|
23
|
+
|
|
24
|
+
# The gem's private methods these hooks take the place of. A version without them leaves resources and prompts
|
|
25
|
+
# unrecorded, and tools measured as before.
|
|
26
|
+
HOOKED = %i[read_resource_contents get_prompt call_prompt_template_with_args].freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def hookable?
|
|
31
|
+
HOOKED.all? { |name| ::MCP::Server.private_method_defined?(name) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The scheme of an address, which is all of an unknown one that may be kept: `db://`.
|
|
35
|
+
def scheme(uri)
|
|
36
|
+
match = SCHEME.match(uri.to_s)
|
|
37
|
+
match ? "#{match[1]}://" : "unknown://"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The name a read is recorded under, and the template's variables when a template matched it.
|
|
41
|
+
def resolve(uri, index, templates)
|
|
42
|
+
return [uri, nil] if index.is_a?(Hash) && index.key?(uri)
|
|
43
|
+
|
|
44
|
+
Array(templates).each do |template|
|
|
45
|
+
pattern = template.uri_template
|
|
46
|
+
next unless pattern.is_a?(String)
|
|
47
|
+
|
|
48
|
+
variables = match(pattern, uri)
|
|
49
|
+
return [pattern, variables] if variables
|
|
50
|
+
end
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def match(template, uri)
|
|
55
|
+
pattern = Regexp.escape(template).gsub(VARIABLE) { "(?<#{Regexp.last_match(1)}>[^/]+)" }
|
|
56
|
+
Regexp.new("\\A#{pattern}\\z").match(uri)&.named_captures
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Hooks prepended to one server's singleton class.
|
|
60
|
+
module ServerHooks
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def read_resource_contents(request, session: nil, **rest)
|
|
64
|
+
call, known = __mcpspan_begin_read(request, session, rest[:envelope])
|
|
65
|
+
return super if call.nil?
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
result = super
|
|
69
|
+
rescue ::MCP::CancelledError
|
|
70
|
+
raise
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
__mcpspan_read_failed(call, known, e)
|
|
73
|
+
raise
|
|
74
|
+
end
|
|
75
|
+
__mcpspan_succeeded(call, result)
|
|
76
|
+
result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def get_prompt(request, session: nil, **rest)
|
|
80
|
+
call, known = __mcpspan_begin_prompt(request, session, rest[:envelope])
|
|
81
|
+
return super if call.nil?
|
|
82
|
+
|
|
83
|
+
reached = [false]
|
|
84
|
+
outer = Thread.current[PROMPTING]
|
|
85
|
+
Thread.current[PROMPTING] = reached
|
|
86
|
+
begin
|
|
87
|
+
result = super
|
|
88
|
+
rescue ::MCP::CancelledError
|
|
89
|
+
raise
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
__mcpspan_prompt_failed(call, known, reached[0], e)
|
|
92
|
+
raise
|
|
93
|
+
ensure
|
|
94
|
+
Thread.current[PROMPTING] = outer
|
|
95
|
+
end
|
|
96
|
+
__mcpspan_succeeded(call, result)
|
|
97
|
+
result
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def call_prompt_template_with_args(*args, **kwargs)
|
|
101
|
+
reached = Thread.current[PROMPTING]
|
|
102
|
+
reached[0] = true if reached
|
|
103
|
+
super
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def __mcpspan_begin_read(request, session, envelope)
|
|
107
|
+
return nil unless Collector.collecting? && request.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
uri = request[:uri].to_s
|
|
110
|
+
name, variables = Primitives.resolve(uri, @resource_index, @resource_templates)
|
|
111
|
+
known = !name.nil?
|
|
112
|
+
call = Collector.begin_call(
|
|
113
|
+
known ? name : Primitives.scheme(uri),
|
|
114
|
+
arguments: variables,
|
|
115
|
+
session_id: Instrumentation.session_id(session),
|
|
116
|
+
server_version: Instrumentation.server_version(self),
|
|
117
|
+
**Instrumentation.client(envelope, session, self),
|
|
118
|
+
kind: "resource",
|
|
119
|
+
)
|
|
120
|
+
call && [call, known]
|
|
121
|
+
rescue *Instrumentation::INTERNAL
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def __mcpspan_begin_prompt(request, session, envelope)
|
|
126
|
+
return nil unless Collector.collecting? && request.is_a?(Hash)
|
|
127
|
+
|
|
128
|
+
name = request[:name]
|
|
129
|
+
call = Collector.begin_call(
|
|
130
|
+
name,
|
|
131
|
+
arguments: request[:arguments],
|
|
132
|
+
session_id: Instrumentation.session_id(session),
|
|
133
|
+
server_version: Instrumentation.server_version(self),
|
|
134
|
+
**Instrumentation.client(envelope, session, self),
|
|
135
|
+
kind: "prompt",
|
|
136
|
+
)
|
|
137
|
+
call && [call, @prompts.key?(name)]
|
|
138
|
+
rescue *Instrumentation::INTERNAL
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def __mcpspan_succeeded(call, result)
|
|
143
|
+
# An interim result asking the client for input settles nothing; the request that follows it does.
|
|
144
|
+
Collector.record(call, success: true) unless Instrumentation.interim?(result)
|
|
145
|
+
rescue *Instrumentation::INTERNAL
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def __mcpspan_read_failed(call, known, error)
|
|
150
|
+
if !known && Instrumentation.named?(error, "MCP::Server::ResourceNotFoundError")
|
|
151
|
+
Collector.record(call, success: false, source: Source::UNKNOWN_RESOURCE)
|
|
152
|
+
else
|
|
153
|
+
__mcpspan_exception(call, error)
|
|
154
|
+
end
|
|
155
|
+
rescue *Instrumentation::INTERNAL
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def __mcpspan_prompt_failed(call, known, reached, error)
|
|
160
|
+
if !known
|
|
161
|
+
Collector.record(call, success: false, source: Source::UNKNOWN_PROMPT)
|
|
162
|
+
elsif !reached
|
|
163
|
+
# Only the gem's check for missing arguments stands between finding the prompt and calling it.
|
|
164
|
+
Collector.record(call, success: false, source: Source::ARGUMENTS)
|
|
165
|
+
else
|
|
166
|
+
__mcpspan_exception(call, error)
|
|
167
|
+
end
|
|
168
|
+
rescue *Instrumentation::INTERNAL
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def __mcpspan_exception(call, error)
|
|
173
|
+
original = error.respond_to?(:original_error) && error.original_error ? error.original_error : error
|
|
174
|
+
type, message = Instrumentation.exception(original)
|
|
175
|
+
Collector.record(call, success: false, source: Source::EXCEPTION, type: type, message: message)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module McpSpan
|
|
6
|
+
# Collects events and delivers them from a thread of its own.
|
|
7
|
+
#
|
|
8
|
+
# `record` is the only method a tool call touches, and it only appends to memory under a lock: the call returns
|
|
9
|
+
# without waiting on the network.
|
|
10
|
+
class Reporter
|
|
11
|
+
DEFAULT_FLUSH_INTERVAL = 5.0
|
|
12
|
+
DEFAULT_MAX_BATCH_SIZE = 100
|
|
13
|
+
DEFAULT_MAX_QUEUE_SIZE = 10_000
|
|
14
|
+
|
|
15
|
+
# The wait after the n-th consecutive failure: doubling to a ceiling, spread over its second half.
|
|
16
|
+
def self.backoff(failures, random = rand)
|
|
17
|
+
ceiling = [1.0 * (2**[failures - 1, 16].min), 60.0].min
|
|
18
|
+
(ceiling / 2) + (random * ceiling / 2)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(endpoint:, send:, flush_interval:, max_batch_size:, max_queue_size:, debug:, on_diagnostic:)
|
|
22
|
+
@endpoint = endpoint
|
|
23
|
+
@send = send
|
|
24
|
+
@flush_interval = flush_interval
|
|
25
|
+
@max_batch_size = max_batch_size
|
|
26
|
+
@max_queue_size = max_queue_size
|
|
27
|
+
@debug = debug
|
|
28
|
+
@on_diagnostic = on_diagnostic
|
|
29
|
+
@lock = Mutex.new
|
|
30
|
+
@wake = ConditionVariable.new
|
|
31
|
+
# One delivery at a time, so the same events are never posted twice.
|
|
32
|
+
@sending = Mutex.new
|
|
33
|
+
@queue = []
|
|
34
|
+
@dropped = 0
|
|
35
|
+
@reported_drops = 0
|
|
36
|
+
@failures = 0
|
|
37
|
+
@next_attempt = nil
|
|
38
|
+
@woken = false
|
|
39
|
+
@stopped = false
|
|
40
|
+
@rejected = false
|
|
41
|
+
@thread = nil
|
|
42
|
+
@pid = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Starts delivery, announcing the server first (contract, 3.4).
|
|
46
|
+
def start
|
|
47
|
+
@lock.synchronize { spawn }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Queues an event and returns at once.
|
|
51
|
+
def record(event)
|
|
52
|
+
@lock.synchronize do
|
|
53
|
+
return if @stopped || @rejected
|
|
54
|
+
|
|
55
|
+
# A forked child, as a web server's worker is, inherits the queue but not the thread.
|
|
56
|
+
if @pid != Process.pid
|
|
57
|
+
@queue.clear
|
|
58
|
+
spawn
|
|
59
|
+
end
|
|
60
|
+
if @queue.size >= @max_queue_size
|
|
61
|
+
@queue.shift
|
|
62
|
+
@dropped += 1
|
|
63
|
+
end
|
|
64
|
+
@queue << event
|
|
65
|
+
if @queue.size >= @max_batch_size
|
|
66
|
+
@woken = true
|
|
67
|
+
@wake.signal
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Delivers what is queued now, ignoring any retry delay, and carries on.
|
|
73
|
+
def flush
|
|
74
|
+
deliver(force: true)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Stops delivery and makes a final attempt at what is queued, ignoring any retry delay: this is the last chance
|
|
78
|
+
# these events get. A delivery already under way is waited for, rather than its events posted twice.
|
|
79
|
+
def stop
|
|
80
|
+
thread = @lock.synchronize do
|
|
81
|
+
@stopped = true
|
|
82
|
+
@woken = true
|
|
83
|
+
@wake.signal
|
|
84
|
+
@thread if @pid == Process.pid
|
|
85
|
+
end
|
|
86
|
+
thread&.join(Transport::TIMEOUT + 1)
|
|
87
|
+
deliver(force: true)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def spawn
|
|
93
|
+
@pid = Process.pid
|
|
94
|
+
@thread = Thread.new { run }
|
|
95
|
+
@thread.name = "mcpspan-delivery"
|
|
96
|
+
# A delivery thread must never be why a program does not exit, nor print a stray backtrace.
|
|
97
|
+
@thread.report_on_exception = false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def run
|
|
101
|
+
announce
|
|
102
|
+
loop do
|
|
103
|
+
@lock.synchronize do
|
|
104
|
+
deadline = monotonic + @flush_interval
|
|
105
|
+
until @woken || @stopped || @rejected
|
|
106
|
+
left = deadline - monotonic
|
|
107
|
+
break if left <= 0
|
|
108
|
+
|
|
109
|
+
@wake.wait(@lock, left)
|
|
110
|
+
end
|
|
111
|
+
@woken = false
|
|
112
|
+
return if @stopped || @rejected
|
|
113
|
+
end
|
|
114
|
+
deliver(force: false)
|
|
115
|
+
end
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
log("mcpspan: delivery stopped (#{e.class}: #{e.message})")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def announce
|
|
121
|
+
failure = @send.call([])
|
|
122
|
+
return if failure.nil?
|
|
123
|
+
|
|
124
|
+
if [401, 403].include?(failure.status)
|
|
125
|
+
reject(failure.status)
|
|
126
|
+
else
|
|
127
|
+
log("mcpspan: could not announce this server to #{@endpoint} (#{failure.message}). " \
|
|
128
|
+
"Events will still be delivered once it answers.")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def deliver(force:)
|
|
133
|
+
@lock.synchronize do
|
|
134
|
+
return if @rejected
|
|
135
|
+
return if !force && @next_attempt && monotonic < @next_attempt
|
|
136
|
+
end
|
|
137
|
+
@sending.synchronize do
|
|
138
|
+
report_drops
|
|
139
|
+
loop do
|
|
140
|
+
batch = @lock.synchronize do
|
|
141
|
+
return if @rejected
|
|
142
|
+
|
|
143
|
+
@queue.shift(@max_batch_size)
|
|
144
|
+
end
|
|
145
|
+
return if batch.empty?
|
|
146
|
+
|
|
147
|
+
failure = @send.call(batch)
|
|
148
|
+
if failure
|
|
149
|
+
failed(batch, failure)
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
@lock.synchronize do
|
|
153
|
+
@failures = 0
|
|
154
|
+
@next_attempt = nil
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def report_drops
|
|
161
|
+
dropped = @lock.synchronize do
|
|
162
|
+
count = @dropped - @reported_drops
|
|
163
|
+
@reported_drops = @dropped
|
|
164
|
+
count
|
|
165
|
+
end
|
|
166
|
+
log("mcpspan: discarded #{dropped} events, the queue was full") if dropped.positive?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def failed(batch, failure)
|
|
170
|
+
return reject(failure.status) if [401, 403].include?(failure.status)
|
|
171
|
+
|
|
172
|
+
attempt = @lock.synchronize do
|
|
173
|
+
if failure.retryable
|
|
174
|
+
@queue.unshift(*batch)
|
|
175
|
+
while @queue.size > @max_queue_size
|
|
176
|
+
@queue.shift
|
|
177
|
+
@dropped += 1
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
@failures += 1
|
|
181
|
+
# The longer of our own backoff and what the API asked for.
|
|
182
|
+
@next_attempt = monotonic + [self.class.backoff(@failures), failure.retry_after].max
|
|
183
|
+
@failures
|
|
184
|
+
end
|
|
185
|
+
# Refused the same way every time: dropped, and collecting goes on.
|
|
186
|
+
log("mcpspan: dropped #{batch.size} events, rejected as #{failure.status}") unless failure.retryable
|
|
187
|
+
log("mcpspan: delivery failed (#{failure.message}), attempt #{attempt}")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Gives up on a key the endpoint refused, and says so once even with diagnostics off: a silent SDK collecting
|
|
191
|
+
# nothing because of a mistyped key is the worst way to spend an afternoon.
|
|
192
|
+
def reject(status)
|
|
193
|
+
@lock.synchronize do
|
|
194
|
+
return if @rejected
|
|
195
|
+
|
|
196
|
+
@rejected = true
|
|
197
|
+
@queue.clear
|
|
198
|
+
@wake.signal
|
|
199
|
+
end
|
|
200
|
+
warn_always("mcpspan: the ingest endpoint rejected the API key (HTTP #{status}). " \
|
|
201
|
+
"Telemetry is now disabled for this process.")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def log(message)
|
|
205
|
+
warn_always(message) if @debug
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The developer's callback if given, otherwise standard error. Never standard output: on the stdio transport it
|
|
209
|
+
# carries the MCP protocol, and a stray line there breaks the server.
|
|
210
|
+
def warn_always(message)
|
|
211
|
+
if @on_diagnostic
|
|
212
|
+
@on_diagnostic.call(message)
|
|
213
|
+
else
|
|
214
|
+
warn(message)
|
|
215
|
+
end
|
|
216
|
+
rescue StandardError
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def monotonic
|
|
221
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
data/lib/mcpspan/text.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module McpSpan
|
|
4
|
+
# Limits the ingest API enforces, and the text that has to fit them. A batch holding one field over its limit is
|
|
5
|
+
# refused whole, so everything that comes from outside the developer's control is cut before it is sent.
|
|
6
|
+
module Text
|
|
7
|
+
MAX_NAME = 200
|
|
8
|
+
MAX_EXCEPTION_MESSAGE = 500
|
|
9
|
+
MAX_RESULT_MESSAGE = 200
|
|
10
|
+
MAX_DESCRIBED_PARAMETERS = 50
|
|
11
|
+
# A release, a tag, a commit: the server's or the client's.
|
|
12
|
+
MAX_VERSION = 100
|
|
13
|
+
|
|
14
|
+
# First match wins, so `claude-code` is tested before `claude`.
|
|
15
|
+
CLIENT_TYPES = [
|
|
16
|
+
[["claude-code", "claude code"], "claude-code"],
|
|
17
|
+
[["claude"], "claude"],
|
|
18
|
+
[["cursor"], "cursor"],
|
|
19
|
+
[%w[chatgpt openai], "chatgpt"],
|
|
20
|
+
[["inspector"], "mcp-inspector"],
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Cuts text to a limit in characters, leaving a visible sign that something was removed.
|
|
26
|
+
def truncate(text, limit)
|
|
27
|
+
text = text.to_s
|
|
28
|
+
text.length <= limit ? text : "#{text[0, limit - 3]}..."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def client_type(name)
|
|
32
|
+
return "unknown" if name.nil? || name.strip.empty?
|
|
33
|
+
|
|
34
|
+
lower = name.downcase
|
|
35
|
+
CLIENT_TYPES.each do |needles, type|
|
|
36
|
+
return type if needles.any? { |needle| lower.include?(needle) }
|
|
37
|
+
end
|
|
38
|
+
"other"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def client_name(name)
|
|
42
|
+
return nil if name.nil? || name.strip.empty?
|
|
43
|
+
|
|
44
|
+
truncate(name, MAX_NAME)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def version(version)
|
|
48
|
+
version = version.to_s.strip
|
|
49
|
+
version.empty? ? nil : truncate(version, MAX_VERSION)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Parameter names and their JSON types. Values are never read beyond their type.
|
|
53
|
+
def describe_parameters(arguments)
|
|
54
|
+
return nil unless arguments.is_a?(Hash) && !arguments.empty?
|
|
55
|
+
|
|
56
|
+
arguments.first(MAX_DESCRIBED_PARAMETERS).to_h do |name, value|
|
|
57
|
+
[truncate(name, MAX_NAME), json_type(value)]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def json_type(value)
|
|
62
|
+
case value
|
|
63
|
+
when nil then "null"
|
|
64
|
+
when true, false then "boolean"
|
|
65
|
+
when Numeric then "number"
|
|
66
|
+
when String, Symbol then "string"
|
|
67
|
+
when Array then "array"
|
|
68
|
+
else "object"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|