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,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "time"
5
+ require "uri"
6
+
7
+ module McpSpan
8
+ # A delivery that did not succeed, and whether sending the same batch again could work.
9
+ Failure = Struct.new(:message, :status, :retryable, :retry_after, keyword_init: true)
10
+
11
+ # Posts batches to the ingest API. It neither retries nor swallows: it answers nil for a delivery, or a Failure.
12
+ class Transport
13
+ TIMEOUT = 10
14
+ # The longest Retry-After followed: a server asking for longer is wrong or unwell.
15
+ MAX_RETRY_AFTER = 300
16
+
17
+ def initialize(endpoint, api_key)
18
+ @uri = URI.join("#{endpoint.chomp("/")}/", "v1/events")
19
+ @headers = {
20
+ "Content-Type" => "application/json",
21
+ "Authorization" => "Bearer #{api_key}",
22
+ "User-Agent" => "mcpspan/#{VERSION} (ruby)",
23
+ }
24
+ end
25
+
26
+ def call(events)
27
+ http = Net::HTTP.new(@uri.host, @uri.port)
28
+ http.use_ssl = @uri.scheme == "https"
29
+ http.open_timeout = http.read_timeout = http.write_timeout = TIMEOUT
30
+ # Net::HTTP never follows a redirect, which is what we want: a redirected POST delivers nothing.
31
+ response = http.post(@uri.request_uri, Event.batch(events), @headers)
32
+ status = response.code.to_i
33
+ return nil if (200..299).cover?(status)
34
+
35
+ Failure.new(
36
+ message: "ingest API answered #{status}",
37
+ status: status,
38
+ retryable: status == 408 || status == 429 || status >= 500,
39
+ retry_after: self.class.retry_after(response["retry-after"]),
40
+ )
41
+ rescue StandardError => e
42
+ # Unreachable, reset, timed out: the moment, not the batch.
43
+ Failure.new(message: "failed to reach #{@uri} (#{e.class}: #{e.message})", status: nil, retryable: true,
44
+ retry_after: 0,)
45
+ end
46
+
47
+ # Retry-After in either form, whole seconds or an HTTP date. Zero leaves the SDK's own backoff to decide.
48
+ def self.retry_after(value, now: Time.now)
49
+ return 0 if value.nil?
50
+
51
+ value = value.strip
52
+ wait = if value.match?(/\A\d+\z/)
53
+ value.to_i
54
+ else
55
+ begin
56
+ Time.httpdate(value) - now
57
+ rescue ArgumentError
58
+ 0
59
+ end
60
+ end
61
+ wait.clamp(0, MAX_RETRY_AFTER)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module McpSpan
4
+ # The SDK's own version, reported with every event.
5
+ VERSION = "0.1.0"
6
+ end
data/lib/mcpspan.rb ADDED
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mcpspan/version"
4
+ require_relative "mcpspan/text"
5
+ require_relative "mcpspan/event"
6
+ require_relative "mcpspan/transport"
7
+ require_relative "mcpspan/reporter"
8
+ require_relative "mcpspan/collector"
9
+ require_relative "mcpspan/instrumentation"
10
+ require_relative "mcpspan/primitives"
11
+
12
+ # Analytics for MCP servers: which tools are called, by which client, how long they take, and which ones fail.
13
+ #
14
+ # server = MCP::Server.new(name: "flights", tools: [SearchFlights])
15
+ # McpSpan.instrument(server, api_key: ENV["MCPSPAN_API_KEY"], endpoint: "http://localhost:6271")
16
+ #
17
+ # Without an API key nothing is collected and nothing is sent. Parameter values never leave the process.
18
+ #
19
+ # Settings: +api_key+ (else +MCPSPAN_API_KEY+), +endpoint+, your mcpspan installation (else +MCPSPAN_ENDPOINT+; no
20
+ # default, and nothing is collected without it), +capture_parameter_names+, +debug+, +on_diagnostic+,
21
+ # +flush_on_exit+, +flush_interval+ (seconds), +max_batch_size+, +max_queue_size+. Nothing here raises over a setting.
22
+ module McpSpan
23
+ @excluded_names = Set.new
24
+
25
+ class << self
26
+ # Tool names left out with {exclude}.
27
+ attr_reader :excluded_names
28
+
29
+ # Measures every tool on a server built on the `mcp` gem, whether it was added before this call or after.
30
+ # Configures with +settings+ when given, else from the environment unless already configured. Returns the
31
+ # server.
32
+ def instrument(server, **settings)
33
+ if !settings.empty?
34
+ configure(**settings)
35
+ elsif !collecting?
36
+ configure
37
+ end
38
+ Instrumentation.instrument(server)
39
+ rescue StandardError
40
+ server
41
+ end
42
+
43
+ # Starts collecting, or stops if there is no key to collect with. Configuring again with the same settings
44
+ # changes nothing, so a server built per request can call it every time; different settings replace the running
45
+ # configuration, delivering what it held.
46
+ def configure(**settings)
47
+ Collector.configure(settings)
48
+ nil
49
+ rescue StandardError => e
50
+ warn("mcpspan: could not configure (#{e.class}: #{e.message})") if settings[:debug]
51
+ nil
52
+ end
53
+
54
+ # Stops collecting and delivers what is queued, ignoring any wait for a retry: it is the last chance these
55
+ # events get. What is queued is also delivered as the program exits, so most servers need not call this.
56
+ def shutdown
57
+ Collector.shutdown
58
+ nil
59
+ rescue StandardError
60
+ nil
61
+ end
62
+
63
+ # Whether an API key is configured and calls are being recorded.
64
+ def collecting?
65
+ Collector.collecting?
66
+ end
67
+
68
+ # Measures one tool class, for a server {instrument} does not cover. On an instrumented server it is counted
69
+ # once. Returns the tool.
70
+ def track(tool)
71
+ if tool.respond_to?(:call) && tool.respond_to?(:name_value) && !tool.singleton_class.include?(Instrumentation::ToolHooks)
72
+ tool.singleton_class.prepend(Instrumentation::ToolHooks)
73
+ end
74
+ tool
75
+ end
76
+
77
+ # Leaves a tool out: its calls, refused ones included, are not recorded. Takes the tool class, so a rename
78
+ # carries the exclusion along, or its name, for a tool made with +define_tool+. Returns what it was given.
79
+ def exclude(tool)
80
+ if tool.is_a?(String) || tool.is_a?(Symbol)
81
+ @excluded_names << tool.to_s
82
+ else
83
+ tool.instance_variable_set(:@__mcpspan_excluded, true)
84
+ end
85
+ tool
86
+ end
87
+ end
88
+ end
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mcpspan
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kacper Zatoń
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'Measures the tools of a server built on the official Ruby MCP SDK: which
13
+ get called, by which client, how long they take and which fail. Parameter values
14
+ never leave the process.'
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE
20
+ - README.md
21
+ - lib/mcpspan.rb
22
+ - lib/mcpspan/collector.rb
23
+ - lib/mcpspan/event.rb
24
+ - lib/mcpspan/instrumentation.rb
25
+ - lib/mcpspan/primitives.rb
26
+ - lib/mcpspan/reporter.rb
27
+ - lib/mcpspan/text.rb
28
+ - lib/mcpspan/transport.rb
29
+ - lib/mcpspan/version.rb
30
+ homepage: https://github.com/mcpspan/mcpspan
31
+ licenses:
32
+ - MIT
33
+ metadata:
34
+ source_code_uri: https://github.com/mcpspan/mcpspan/tree/main/packages/mcpspan-ruby
35
+ bug_tracker_uri: https://github.com/mcpspan/mcpspan/issues
36
+ rubygems_mfa_required: 'true'
37
+ rdoc_options: []
38
+ require_paths:
39
+ - lib
40
+ required_ruby_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '3.2'
45
+ required_rubygems_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ requirements: []
51
+ rubygems_version: 4.0.20
52
+ specification_version: 4
53
+ summary: 'Self-hosted analytics for MCP servers: which tools, resources and prompts
54
+ get used, by which client, how fast, and why they fail.'
55
+ test_files: []