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.
@@ -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,9 @@
1
+ module McpLogs
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace McpLogs
4
+
5
+ rake_tasks do
6
+ load File.expand_path("../tasks/mcp_logs.rake", __dir__)
7
+ end
8
+ end
9
+ 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
@@ -0,0 +1,3 @@
1
+ module McpLogs
2
+ VERSION = "0.1.0"
3
+ 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: []