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,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
|
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: []
|