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.
@@ -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
@@ -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