mcp_logs 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.txt +21 -0
- data/README.md +274 -0
- data/Rakefile +11 -0
- data/app/assets/config/mcp_logs_manifest.js +1 -0
- data/app/assets/stylesheets/mcp_logs.css +138 -0
- data/app/controllers/mcp_logs/application_controller.rb +12 -0
- data/app/controllers/mcp_logs/calls_controller.rb +34 -0
- data/app/controllers/mcp_logs/docs_controller.rb +9 -0
- data/app/helpers/mcp_logs/calls_helper.rb +27 -0
- data/app/jobs/mcp_logs/purge_job.rb +14 -0
- data/app/models/mcp_logs/application_record.rb +5 -0
- data/app/models/mcp_logs/call.rb +70 -0
- data/app/models/mcp_logs/docs.rb +81 -0
- data/app/models/mcp_logs/recorder.rb +204 -0
- data/app/views/layouts/mcp_logs/application.html.erb +25 -0
- data/app/views/mcp_logs/calls/_pagination.html.erb +18 -0
- data/app/views/mcp_logs/calls/index.html.erb +62 -0
- data/app/views/mcp_logs/calls/show.html.erb +104 -0
- data/app/views/mcp_logs/docs/show.html.erb +75 -0
- data/config/routes.rb +7 -0
- data/db/migrate/001_create_mcp_logs_calls.rb +49 -0
- data/lib/generators/mcp_logs/install_generator.rb +17 -0
- data/lib/generators/mcp_logs/templates/initializer.rb +36 -0
- data/lib/mcp_logs/configuration.rb +31 -0
- data/lib/mcp_logs/context.rb +42 -0
- data/lib/mcp_logs/engine.rb +9 -0
- data/lib/mcp_logs/payload.rb +43 -0
- data/lib/mcp_logs/version.rb +3 -0
- data/lib/mcp_logs.rb +92 -0
- data/lib/tasks/mcp_logs.rake +10 -0
- metadata +114 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
McpLogs.setup do |config|
|
|
2
|
+
# REQUIRED — replace CHANGE_ME before you deploy.
|
|
3
|
+
#
|
|
4
|
+
# These pages render every argument and every response your MCP server has
|
|
5
|
+
# logged. This gem ships no authentication of its own: its controllers descend
|
|
6
|
+
# from one of yours and inherit whatever that controller enforces. Name a
|
|
7
|
+
# controller that requires a signed-in operator, e.g. "Admin::BaseController".
|
|
8
|
+
#
|
|
9
|
+
# Left as-is, the first request to /mcp_logs raises. There is no default,
|
|
10
|
+
# because every default here is either broken or a security hole.
|
|
11
|
+
config.base_controller_class = "CHANGE_ME"
|
|
12
|
+
|
|
13
|
+
# Master switch. When false, nothing is recorded and calls run untouched.
|
|
14
|
+
# config.enabled = true
|
|
15
|
+
|
|
16
|
+
# A lambda returning your MCP::Server. Read by the docs page only.
|
|
17
|
+
# config.server = -> { Mcp::Registry.server }
|
|
18
|
+
|
|
19
|
+
# Group the docs page by tool name. Anything unlisted renders under "Ungrouped".
|
|
20
|
+
# config.tool_sections = { "Read" => %w[search_apps get_app], "Write" => %w[edit_app] }
|
|
21
|
+
|
|
22
|
+
# Payloads are filtered through Rails' filter_parameters, then this lambda,
|
|
23
|
+
# then capped at max_payload_bytes.
|
|
24
|
+
# config.record_payloads = true
|
|
25
|
+
# config.max_payload_bytes = 64_000
|
|
26
|
+
# config.payload_filter = ->(payload) { payload }
|
|
27
|
+
|
|
28
|
+
# Used by McpLogs::PurgeJob and `rake mcp_logs:purge`. Schedule one of them.
|
|
29
|
+
# config.retention_days = 30
|
|
30
|
+
|
|
31
|
+
# The same job settles rows stranded in "running" — a call whose process died
|
|
32
|
+
# before its completion was written. Set this above your slowest tool call.
|
|
33
|
+
# config.abandoned_after_hours = 24
|
|
34
|
+
|
|
35
|
+
# config.page_size = 50
|
|
36
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module McpLogs
|
|
2
|
+
# Raised when the engine is asked to do something the host has not told it how
|
|
3
|
+
# to do. Always fatal at boot or on the first request, never mid-capture.
|
|
4
|
+
class ConfigurationError < StandardError; end
|
|
5
|
+
|
|
6
|
+
class Configuration
|
|
7
|
+
# server: a lambda returning the host's MCP::Server. Read by the
|
|
8
|
+
# docs page only — capture never builds a server.
|
|
9
|
+
# base_controller_class: a String, constantized on first autoload of the engine's
|
|
10
|
+
# ApplicationController, i.e. after host initializers have run.
|
|
11
|
+
# Has no default on purpose — see McpLogs.base_controller.
|
|
12
|
+
# tool_sections: { "Section title" => ["tool_name", ...] }. Names, not classes,
|
|
13
|
+
# so the gem never references host constants.
|
|
14
|
+
attr_accessor :enabled, :server, :base_controller_class, :tool_sections,
|
|
15
|
+
:record_payloads, :max_payload_bytes, :payload_filter,
|
|
16
|
+
:retention_days, :abandoned_after_hours, :page_size
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@enabled = true
|
|
20
|
+
@server = nil
|
|
21
|
+
@base_controller_class = nil
|
|
22
|
+
@tool_sections = {}
|
|
23
|
+
@record_payloads = true
|
|
24
|
+
@max_payload_bytes = 64_000
|
|
25
|
+
@payload_filter = ->(payload) { payload }
|
|
26
|
+
@retention_days = 30
|
|
27
|
+
@abandoned_after_hours = 24
|
|
28
|
+
@page_size = 50
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module McpLogs
|
|
2
|
+
# HTTP-level facts about the request being served. The mcp gem's around_request
|
|
3
|
+
# hook sees none of this, so the host wraps its handler call in
|
|
4
|
+
# McpLogs.with_context(request) and the recorder reads it back here.
|
|
5
|
+
#
|
|
6
|
+
# Fiber storage rather than Thread.current: it follows into fibers the server
|
|
7
|
+
# may spawn, and it is restored on unwind, so a raising handler cannot strand a
|
|
8
|
+
# stale context on a pooled thread.
|
|
9
|
+
module Context
|
|
10
|
+
KEY = :mcp_logs_context
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def current
|
|
15
|
+
Fiber[KEY] || {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def with(request = nil, actor: nil, session_id: nil, protocol_version: nil)
|
|
19
|
+
previous = Fiber[KEY]
|
|
20
|
+
explicit = { actor: actor, session_id: session_id, protocol_version: protocol_version }.compact
|
|
21
|
+
Fiber[KEY] = from_request(request).merge(explicit)
|
|
22
|
+
yield
|
|
23
|
+
ensure
|
|
24
|
+
Fiber[KEY] = previous
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def from_request(request)
|
|
28
|
+
return {} if request.nil?
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
ip: request.respond_to?(:remote_ip) ? request.remote_ip : request.ip,
|
|
32
|
+
user_agent: request.user_agent.presence,
|
|
33
|
+
session_id: header(request, "Mcp-Session-Id"),
|
|
34
|
+
protocol_version: header(request, "Mcp-Protocol-Version")
|
|
35
|
+
}.compact
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def header(request, name)
|
|
39
|
+
request.get_header("HTTP_#{name.upcase.tr("-", "_")}").presence
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module McpLogs
|
|
2
|
+
# Turns a tool's arguments or a handler's result into something safe to store:
|
|
3
|
+
# host-redacted, host-filtered, and bounded in size.
|
|
4
|
+
module Payload
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Returns [value_to_store, truncated?]. A nil value, or payload recording
|
|
8
|
+
# turned off, stores nothing at all.
|
|
9
|
+
def prepare(value)
|
|
10
|
+
return [nil, false] if value.nil?
|
|
11
|
+
return [nil, false] unless McpLogs.record_payloads?
|
|
12
|
+
|
|
13
|
+
filtered = McpLogs.payload_filter.call(parameter_filter.filter(normalize(value)))
|
|
14
|
+
cap(filtered)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# ActiveSupport::ParameterFilter only walks hashes, and the columns are jsonb,
|
|
18
|
+
# so a bare scalar result gets a wrapper rather than being dropped.
|
|
19
|
+
def normalize(value)
|
|
20
|
+
value.is_a?(Hash) ? value.deep_stringify_keys : { "value" => value }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def cap(value)
|
|
24
|
+
json = value.to_json
|
|
25
|
+
return [value, false] if json.bytesize <= McpLogs.max_payload_bytes
|
|
26
|
+
|
|
27
|
+
# byteslice can cut mid-character; scrub keeps the column valid UTF-8.
|
|
28
|
+
preview = json.byteslice(0, McpLogs.max_payload_bytes).scrub("")
|
|
29
|
+
[{ "truncated" => true, "bytes" => json.bytesize, "preview" => preview }, true]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Rebuilt only when reset! is called: compiling the filter's regexps on every
|
|
33
|
+
# logged call would be pure overhead on the request path.
|
|
34
|
+
def parameter_filter
|
|
35
|
+
@parameter_filter ||=
|
|
36
|
+
ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reset!
|
|
40
|
+
@parameter_filter = nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/mcp_logs.rb
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require "pagy"
|
|
2
|
+
require "mcp_logs/version"
|
|
3
|
+
require "mcp_logs/configuration"
|
|
4
|
+
require "mcp_logs/engine"
|
|
5
|
+
require "mcp_logs/context"
|
|
6
|
+
require "mcp_logs/payload"
|
|
7
|
+
|
|
8
|
+
module McpLogs
|
|
9
|
+
# `server` is absent on purpose: McpLogs.server calls the configured lambda
|
|
10
|
+
# rather than returning it, so it gets an explicit reader below and only its
|
|
11
|
+
# writer is generated here.
|
|
12
|
+
SETTINGS = %i[
|
|
13
|
+
enabled base_controller_class tool_sections record_payloads
|
|
14
|
+
max_payload_bytes payload_filter retention_days abandoned_after_hours page_size
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
MISSING_BASE_CONTROLLER = <<~MESSAGE.freeze
|
|
18
|
+
McpLogs has no base_controller_class, so its pages would have no authentication.
|
|
19
|
+
|
|
20
|
+
Set it in config/initializers/mcp_logs.rb to a controller of your own whose
|
|
21
|
+
authentication the log pages should inherit:
|
|
22
|
+
|
|
23
|
+
config.base_controller_class = "Admin::BaseController"
|
|
24
|
+
|
|
25
|
+
Use "ActionController::Base" only if you intend /mcp_logs — every tool
|
|
26
|
+
argument and response your MCP server has recorded — to be readable by
|
|
27
|
+
anyone who can reach the app.
|
|
28
|
+
MESSAGE
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def setup
|
|
32
|
+
yield configuration
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def configuration
|
|
36
|
+
@configuration ||= Configuration.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Specs and hosts that reconfigure at runtime need a clean slate.
|
|
40
|
+
def reset_configuration!
|
|
41
|
+
@configuration = Configuration.new
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# One reader and one writer per setting, so `McpLogs.page_size = 1` works in a
|
|
45
|
+
# spec and `McpLogs.page_size` reads naturally in a view.
|
|
46
|
+
SETTINGS.each do |setting|
|
|
47
|
+
define_method(setting) { configuration.public_send(setting) }
|
|
48
|
+
define_method(:"#{setting}=") { |value| configuration.public_send(:"#{setting}=", value) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def enabled? = !!configuration.enabled
|
|
52
|
+
|
|
53
|
+
def record_payloads? = !!configuration.record_payloads
|
|
54
|
+
|
|
55
|
+
# Calls the configured lambda. Returns nil when no server is configured, which
|
|
56
|
+
# the docs page renders as "no server configured" rather than raising.
|
|
57
|
+
def server = configuration.server&.call
|
|
58
|
+
|
|
59
|
+
def server=(value)
|
|
60
|
+
configuration.server = value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Resolved when the engine's ApplicationController is first autoloaded, i.e.
|
|
64
|
+
# after the host's initializers have run.
|
|
65
|
+
#
|
|
66
|
+
# There is no default. Every page this engine serves renders arguments and
|
|
67
|
+
# responses the host's MCP server has logged, so an engine that quietly
|
|
68
|
+
# descended from ActionController::Base would publish the audit log of any
|
|
69
|
+
# app that installed it without reading the whole README. Failing here costs
|
|
70
|
+
# a stranger one boot; the alternative costs them the log.
|
|
71
|
+
def base_controller
|
|
72
|
+
configured = configuration.base_controller_class.to_s
|
|
73
|
+
raise ConfigurationError, MISSING_BASE_CONTROLLER if configured.empty?
|
|
74
|
+
|
|
75
|
+
configured.constantize
|
|
76
|
+
rescue NameError
|
|
77
|
+
# The generated initializer ships a placeholder, so "set but not resolvable"
|
|
78
|
+
# is the likeliest way a host gets here — and a bare NameError names the
|
|
79
|
+
# constant without saying which setting produced it.
|
|
80
|
+
raise ConfigurationError, "McpLogs base_controller_class is #{configured.inspect}, " \
|
|
81
|
+
"which does not name a controller. #{MISSING_BASE_CONTROLLER}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def around_request(server_name: nil)
|
|
85
|
+
McpLogs::Recorder.around_request(server_name: server_name)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def with_context(request = nil, **options, &block)
|
|
89
|
+
McpLogs::Context.with(request, **options, &block)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
namespace :mcp_logs do
|
|
2
|
+
desc "Delete logged MCP calls past McpLogs.retention_days, and settle any stranded in running"
|
|
3
|
+
task purge: :environment do
|
|
4
|
+
deleted = McpLogs::Call.purge!
|
|
5
|
+
puts "mcp_logs: deleted #{deleted} calls older than #{McpLogs.retention_days} days"
|
|
6
|
+
|
|
7
|
+
swept = McpLogs::Call.sweep_abandoned!
|
|
8
|
+
puts "mcp_logs: marked #{swept} calls abandoned after #{McpLogs.abandoned_after_hours} hours running"
|
|
9
|
+
end
|
|
10
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mcp_logs
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Anton
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: pagy
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '43.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '43.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: mcp
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.0'
|
|
54
|
+
description: Mountable Rails engine that records every Model Context Protocol request
|
|
55
|
+
a Rails app serves and renders its tool contract as documentation.
|
|
56
|
+
executables: []
|
|
57
|
+
extensions: []
|
|
58
|
+
extra_rdoc_files: []
|
|
59
|
+
files:
|
|
60
|
+
- LICENSE.txt
|
|
61
|
+
- README.md
|
|
62
|
+
- Rakefile
|
|
63
|
+
- app/assets/config/mcp_logs_manifest.js
|
|
64
|
+
- app/assets/stylesheets/mcp_logs.css
|
|
65
|
+
- app/controllers/mcp_logs/application_controller.rb
|
|
66
|
+
- app/controllers/mcp_logs/calls_controller.rb
|
|
67
|
+
- app/controllers/mcp_logs/docs_controller.rb
|
|
68
|
+
- app/helpers/mcp_logs/calls_helper.rb
|
|
69
|
+
- app/jobs/mcp_logs/purge_job.rb
|
|
70
|
+
- app/models/mcp_logs/application_record.rb
|
|
71
|
+
- app/models/mcp_logs/call.rb
|
|
72
|
+
- app/models/mcp_logs/docs.rb
|
|
73
|
+
- app/models/mcp_logs/recorder.rb
|
|
74
|
+
- app/views/layouts/mcp_logs/application.html.erb
|
|
75
|
+
- app/views/mcp_logs/calls/_pagination.html.erb
|
|
76
|
+
- app/views/mcp_logs/calls/index.html.erb
|
|
77
|
+
- app/views/mcp_logs/calls/show.html.erb
|
|
78
|
+
- app/views/mcp_logs/docs/show.html.erb
|
|
79
|
+
- config/routes.rb
|
|
80
|
+
- db/migrate/001_create_mcp_logs_calls.rb
|
|
81
|
+
- lib/generators/mcp_logs/install_generator.rb
|
|
82
|
+
- lib/generators/mcp_logs/templates/initializer.rb
|
|
83
|
+
- lib/mcp_logs.rb
|
|
84
|
+
- lib/mcp_logs/configuration.rb
|
|
85
|
+
- lib/mcp_logs/context.rb
|
|
86
|
+
- lib/mcp_logs/engine.rb
|
|
87
|
+
- lib/mcp_logs/payload.rb
|
|
88
|
+
- lib/mcp_logs/version.rb
|
|
89
|
+
- lib/tasks/mcp_logs.rake
|
|
90
|
+
homepage: https://github.com/tonic20/mcp_logs
|
|
91
|
+
licenses:
|
|
92
|
+
- MIT
|
|
93
|
+
metadata:
|
|
94
|
+
homepage_uri: https://github.com/tonic20/mcp_logs
|
|
95
|
+
source_code_uri: https://github.com/tonic20/mcp_logs
|
|
96
|
+
changelog_uri: https://github.com/tonic20/mcp_logs/blob/main/CHANGELOG.md
|
|
97
|
+
rdoc_options: []
|
|
98
|
+
require_paths:
|
|
99
|
+
- lib
|
|
100
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
101
|
+
requirements:
|
|
102
|
+
- - ">="
|
|
103
|
+
- !ruby/object:Gem::Version
|
|
104
|
+
version: '3.3'
|
|
105
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0'
|
|
110
|
+
requirements: []
|
|
111
|
+
rubygems_version: 3.7.2
|
|
112
|
+
specification_version: 4
|
|
113
|
+
summary: Rails engine for MCP request logging and tool documentation
|
|
114
|
+
test_files: []
|