mcp_toolkit 0.3.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +432 -2
- data/README.md +315 -34
- data/config/routes.rb +20 -5
- data/lib/mcp_toolkit/auth/authority.rb +2 -2
- data/lib/mcp_toolkit/authority/composite_tool_provider.rb +32 -0
- data/lib/mcp_toolkit/authority/context.rb +45 -0
- data/lib/mcp_toolkit/authority/controller_methods.rb +423 -0
- data/lib/mcp_toolkit/authority/registry_tool_provider.rb +69 -0
- data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
- data/lib/mcp_toolkit/authority/token.rb +124 -0
- data/lib/mcp_toolkit/authority/tools/base.rb +150 -0
- data/lib/mcp_toolkit/authority/tools/get.rb +59 -0
- data/lib/mcp_toolkit/authority/tools/list.rb +132 -0
- data/lib/mcp_toolkit/authority/tools/resource_schema.rb +52 -0
- data/lib/mcp_toolkit/authority/tools/resources.rb +55 -0
- data/lib/mcp_toolkit/authority.rb +24 -0
- data/lib/mcp_toolkit/configuration.rb +362 -5
- data/lib/mcp_toolkit/dispatcher.rb +248 -0
- data/lib/mcp_toolkit/engine.rb +26 -8
- data/lib/mcp_toolkit/engine_controllers.rb +124 -0
- data/lib/mcp_toolkit/filtering.rb +168 -23
- data/lib/mcp_toolkit/gateway/aggregator.rb +142 -0
- data/lib/mcp_toolkit/gateway/client.rb +305 -0
- data/lib/mcp_toolkit/gateway/proxy.rb +70 -0
- data/lib/mcp_toolkit/gateway/unknown_upstream.rb +8 -0
- data/lib/mcp_toolkit/gateway/upstream_call_error.rb +23 -0
- data/lib/mcp_toolkit/gateway/upstream_registry.rb +79 -0
- data/lib/mcp_toolkit/list_executor.rb +65 -4
- data/lib/mcp_toolkit/protocol.rb +106 -0
- data/lib/mcp_toolkit/rate_limiter.rb +73 -0
- data/lib/mcp_toolkit/registry.rb +30 -2
- data/lib/mcp_toolkit/resource.rb +173 -6
- data/lib/mcp_toolkit/resource_schema.rb +135 -13
- data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
- data/lib/mcp_toolkit/serializer/base.rb +2 -2
- data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
- data/lib/mcp_toolkit/session.rb +18 -10
- data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
- data/lib/mcp_toolkit/tools/authority_base.rb +148 -0
- data/lib/mcp_toolkit/tools/base.rb +23 -3
- data/lib/mcp_toolkit/tools/get.rb +1 -1
- data/lib/mcp_toolkit/tools/list.rb +27 -9
- data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
- data/lib/mcp_toolkit/tools/resources.rb +30 -7
- data/lib/mcp_toolkit/transport/controller_methods.rb +2 -2
- data/lib/mcp_toolkit/usage_metering/recorder.rb +121 -0
- data/lib/mcp_toolkit/version.rb +1 -1
- data/lib/mcp_toolkit.rb +16 -3
- metadata +42 -2
- data/app/controllers/mcp_toolkit/server_controller.rb +0 -19
data/lib/mcp_toolkit/session.rb
CHANGED
|
@@ -7,24 +7,31 @@
|
|
|
7
7
|
# Cache-backed (rather than the gem's in-process StreamableHTTPTransport) so
|
|
8
8
|
# sessions survive across Puma workers and interoperate with a gateway's client.
|
|
9
9
|
# The cache store + TTL come from McpToolkit.config.
|
|
10
|
+
#
|
|
11
|
+
# A session carries an opaque `data` hash the transport can attach at creation
|
|
12
|
+
# (e.g. `{ token_id: ... }`) so an AUTHORITY can bind a session to a token id —
|
|
13
|
+
# the property that lets a revoked token kill an in-flight session. The gem does
|
|
14
|
+
# NOT interpret `data` (it never re-resolves a token; that's the consumer's auth
|
|
15
|
+
# concern): it stores it, round-trips it, and exposes it via `#data`.
|
|
10
16
|
class McpToolkit::Session
|
|
11
17
|
CACHE_KEY_PREFIX = "mcp_toolkit:session:"
|
|
12
18
|
|
|
13
|
-
def self.create!(config: McpToolkit.config)
|
|
19
|
+
def self.create!(data: {}, config: McpToolkit.config)
|
|
14
20
|
id = SecureRandom.uuid
|
|
15
|
-
config.cache_store.write(cache_key(id), { created_at: Time.now.to_i }, expires_in: config.session_ttl)
|
|
16
|
-
new(id:)
|
|
21
|
+
config.cache_store.write(cache_key(id), { created_at: Time.now.to_i, data: }, expires_in: config.session_ttl)
|
|
22
|
+
new(id:, data:)
|
|
17
23
|
end
|
|
18
24
|
|
|
19
25
|
def self.find(id, config: McpToolkit.config)
|
|
20
26
|
return nil if id.to_s.empty?
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
return nil unless
|
|
28
|
+
stored = config.cache_store.read(cache_key(id))
|
|
29
|
+
return nil unless stored
|
|
24
30
|
|
|
25
|
-
# Sliding expiry: bump TTL on every successful lookup
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
# Sliding expiry: bump TTL on every successful lookup, re-writing the row
|
|
32
|
+
# untouched.
|
|
33
|
+
config.cache_store.write(cache_key(id), stored, expires_in: config.session_ttl)
|
|
34
|
+
new(id:, data: stored[:data] || {})
|
|
28
35
|
end
|
|
29
36
|
|
|
30
37
|
def self.delete(id, config: McpToolkit.config)
|
|
@@ -38,9 +45,10 @@ class McpToolkit::Session
|
|
|
38
45
|
end
|
|
39
46
|
private_class_method :cache_key
|
|
40
47
|
|
|
41
|
-
attr_reader :id
|
|
48
|
+
attr_reader :id, :data
|
|
42
49
|
|
|
43
|
-
def initialize(id:)
|
|
50
|
+
def initialize(id:, data: {})
|
|
44
51
|
@id = id
|
|
52
|
+
@data = data || {}
|
|
45
53
|
end
|
|
46
54
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rewrites backticked generic-tool references ("use the `resources` tool") inside
|
|
4
|
+
# tool prose so they always name the tools exactly as they appear in the serving
|
|
5
|
+
# server's `tools/list`. Two serving paths need this: the authority's own generic
|
|
6
|
+
# tools when the host configures a `generic_tool_name_prefix` (a client would
|
|
7
|
+
# otherwise be pointed at an unprefixed tool that does not exist), and the
|
|
8
|
+
# gateway's aggregated upstream tools, whose names are re-keyed into the
|
|
9
|
+
# `<app>__<tool>` namespace while their prose would otherwise keep naming the
|
|
10
|
+
# upstream's bare tools.
|
|
11
|
+
#
|
|
12
|
+
# Only EXACT backticked base names are rewritten; other backticked terms
|
|
13
|
+
# (`resource`, `filter`, `target_resource`, ...) never match.
|
|
14
|
+
module McpToolkit::ToolReferenceRewriter
|
|
15
|
+
GENERIC_TOOL_REFERENCES = /`(resource_schema|resources|list|get)`/
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Rewrites `node` (a String, or a Hash/Array structure such as a tool
|
|
20
|
+
# definition or input schema, walked recursively) with `name_prefix` applied to
|
|
21
|
+
# every backticked generic-tool reference. Non-string leaves pass through
|
|
22
|
+
# untouched; an empty prefix returns the node verbatim.
|
|
23
|
+
def rewrite(node, name_prefix)
|
|
24
|
+
return node if name_prefix.to_s.empty?
|
|
25
|
+
|
|
26
|
+
case node
|
|
27
|
+
when String then node.gsub(GENERIC_TOOL_REFERENCES) { "`#{name_prefix}#{Regexp.last_match(1)}`" }
|
|
28
|
+
when Hash then node.transform_values { |value| rewrite(value, name_prefix) }
|
|
29
|
+
when Array then node.map { |value| rewrite(value, name_prefix) }
|
|
30
|
+
else node
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Optional base class for a HOST's own tools served by the authority dispatcher
|
|
4
|
+
# (McpToolkit::Dispatcher). A host tool MAY subclass this, or may be any object
|
|
5
|
+
# satisfying the duck-typed tool contract the dispatcher calls:
|
|
6
|
+
#
|
|
7
|
+
# tool.required_permissions_scope -> String | nil (gem's scope gate)
|
|
8
|
+
# tool.call(context:, **arguments) -> Hash | String (gem wraps into
|
|
9
|
+
# { content: [...] })
|
|
10
|
+
#
|
|
11
|
+
# The dispatcher treats the object returned by `provider.find(name)` as the tool;
|
|
12
|
+
# an AuthorityBase SUBCLASS satisfies the contract as CLASS methods (the class
|
|
13
|
+
# `.call` instantiates, runs, and error-maps a single invocation).
|
|
14
|
+
#
|
|
15
|
+
# What this base adds over hand-rolling the contract:
|
|
16
|
+
# * a class DSL (`tool_name` / `description` / `input_schema` /
|
|
17
|
+
# `required_permissions_scope` / `definition`) mirroring the tool-definition
|
|
18
|
+
# shape `tools/list` returns;
|
|
19
|
+
# * per-request accessors (`account` / `principal` / `bearer_token` /
|
|
20
|
+
# `superuser?`) read from the injected Authority::Context;
|
|
21
|
+
# * `ensure_resource_accessible!` to gate a superuser-only resource;
|
|
22
|
+
# * error mapping — an ArgumentError (e.g. a missing required kwarg) becomes an
|
|
23
|
+
# InvalidParams, any other StandardError an InternalError, while a
|
|
24
|
+
# deliberately-raised McpToolkit::Protocol::Error passes through with its own
|
|
25
|
+
# code.
|
|
26
|
+
#
|
|
27
|
+
# The gem NEVER references a host's API layer, serializers, or resource catalog —
|
|
28
|
+
# all of that lives behind the host's `#call`.
|
|
29
|
+
class McpToolkit::Tools::AuthorityBase
|
|
30
|
+
class << self
|
|
31
|
+
attr_reader :_tool_name, :_description, :_input_schema
|
|
32
|
+
|
|
33
|
+
def tool_name(name = nil)
|
|
34
|
+
if name
|
|
35
|
+
@_tool_name = name.to_s
|
|
36
|
+
else
|
|
37
|
+
@_tool_name || self.name.to_s.demodulize.underscore.gsub(/_tool$/, "")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def description(desc = nil)
|
|
42
|
+
@_description = desc if desc
|
|
43
|
+
@_description
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def input_schema(&block)
|
|
47
|
+
@_input_schema = yield if block
|
|
48
|
+
@_input_schema || { type: "object", properties: {} }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# OAuth-style scope (`<app>__<action>`) a token must carry to call this tool,
|
|
52
|
+
# enforced by the dispatcher before the tool runs. Defaults to nil (no scope
|
|
53
|
+
# required). NOT inherited — a subclass that doesn't declare its own scope is
|
|
54
|
+
# unscoped, even if an ancestor declared one.
|
|
55
|
+
def required_permissions_scope(scope = nil)
|
|
56
|
+
@_required_permissions_scope = scope.to_s if scope
|
|
57
|
+
@_required_permissions_scope
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def definition
|
|
61
|
+
{
|
|
62
|
+
name: tool_name,
|
|
63
|
+
description: _description,
|
|
64
|
+
inputSchema: _input_schema || { type: "object", properties: {} }
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The dispatcher's entry point: build an instance bound to this request's
|
|
69
|
+
# context and run it, mapping tool-level errors to protocol errors.
|
|
70
|
+
def call(context:, **arguments)
|
|
71
|
+
new(context:).execute(**arguments)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
attr_reader :context
|
|
76
|
+
|
|
77
|
+
def initialize(context:)
|
|
78
|
+
@context = context
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def account
|
|
82
|
+
context.account
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def principal
|
|
86
|
+
context.principal
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def bearer_token
|
|
90
|
+
context.bearer_token
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Whether the caller is a superuser, per the Context (which duck-types it off
|
|
94
|
+
# the principal). Used to gate resources/tools that expose cross-tenant data.
|
|
95
|
+
def superuser?
|
|
96
|
+
context.superuser?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Guards a resource flagged `superusers_only?`: a non-superuser caller is
|
|
100
|
+
# refused. No-op for unrestricted resources.
|
|
101
|
+
def ensure_resource_accessible!(resource)
|
|
102
|
+
return unless resource.superusers_only?
|
|
103
|
+
return if superuser?
|
|
104
|
+
|
|
105
|
+
raise McpToolkit::Protocol::InvalidRequest, "#{resource.name} is restricted to superuser (user-scoped) MCP tokens"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# ArgumentErrors Ruby itself raises while binding the request arguments to the
|
|
109
|
+
# subclass's `#call` signature: a missing/unknown keyword or wrong arity. Their
|
|
110
|
+
# messages carry only keyword names (the tool's own declared params) and counts,
|
|
111
|
+
# so surfacing them tells a client how to fix the call. Every OTHER ArgumentError
|
|
112
|
+
# is raised from inside the tool's business logic (e.g. `Integer("x")`) and its
|
|
113
|
+
# message may carry SQL, internal class names, or a value — so it is sanitized
|
|
114
|
+
# like any unexpected error rather than relayed verbatim.
|
|
115
|
+
ARGUMENT_BINDING_ERROR_MESSAGE = /\A(missing keyword|unknown keyword|wrong number of arguments)/
|
|
116
|
+
|
|
117
|
+
# Runs the tool's business logic (the subclass's `#call`) with error mapping.
|
|
118
|
+
# Arrives with symbol-keyed arguments from the dispatcher.
|
|
119
|
+
def execute(**arguments)
|
|
120
|
+
call(**arguments)
|
|
121
|
+
rescue McpToolkit::Protocol::Error
|
|
122
|
+
# A deliberately-raised protocol error carries its own JSON-RPC code
|
|
123
|
+
# (e.g. InvalidParams); let it bubble untouched so the client sees it.
|
|
124
|
+
raise
|
|
125
|
+
rescue ArgumentError => e
|
|
126
|
+
raise McpToolkit::Protocol::InvalidParams, e.message if ARGUMENT_BINDING_ERROR_MESSAGE.match?(e.message)
|
|
127
|
+
|
|
128
|
+
raise sanitized_internal_error(e)
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
raise sanitized_internal_error(e)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The subclass implements its business logic here, receiving the tool arguments
|
|
134
|
+
# as keywords and returning a Hash or String.
|
|
135
|
+
def call(**_arguments)
|
|
136
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
# An UNEXPECTED error's message may carry SQL, internal class names, or a
|
|
142
|
+
# hostname — it must not reach the caller (the dispatcher relays a
|
|
143
|
+
# Protocol::Error's message verbatim). Log the detail; return a generic error.
|
|
144
|
+
def sanitized_internal_error(error)
|
|
145
|
+
McpToolkit.config.logger&.error("MCP tool #{self.class} error: #{error.message}\n#{error.backtrace&.join("\n")}")
|
|
146
|
+
McpToolkit::Protocol::InternalError.new("Internal error")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -26,7 +26,7 @@ class McpToolkit::Tools::Base < MCP::Tool
|
|
|
26
26
|
# `required_scope` is the explicitly-declared scope a token must carry (the
|
|
27
27
|
# caller resolves it from the resource — see Registry#required_scope_for).
|
|
28
28
|
# Empty/nil => no scope check (authorized_for_scope? treats "" as a pass).
|
|
29
|
-
def self.with_account(server_context, account_id: nil, required_scope: nil)
|
|
29
|
+
def self.with_account(server_context, account_id: nil, required_scope: nil, resource: nil)
|
|
30
30
|
config = config_from(server_context)
|
|
31
31
|
context = McpToolkit::Auth::Authenticator.call(
|
|
32
32
|
token: server_context[:bearer_token],
|
|
@@ -40,6 +40,9 @@ class McpToolkit::Tools::Base < MCP::Tool
|
|
|
40
40
|
return error_response("Unauthorized: token lacks the #{required_scope.inspect} scope")
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
superuser_refusal = superuser_only_refusal(resource, context.introspection)
|
|
44
|
+
return superuser_refusal if superuser_refusal
|
|
45
|
+
|
|
43
46
|
text_response(yield(context.scope_root))
|
|
44
47
|
rescue McpToolkit::Errors::Unauthorized => e
|
|
45
48
|
error_response("Unauthorized: #{e.message}")
|
|
@@ -52,7 +55,7 @@ class McpToolkit::Tools::Base < MCP::Tool
|
|
|
52
55
|
# which reveal shape, not tenant data, so a superuser shouldn't have to pin an
|
|
53
56
|
# account just to discover what exists. Empty/nil `required_scope` => no scope
|
|
54
57
|
# check.
|
|
55
|
-
def self.with_authentication(server_context, required_scope: nil)
|
|
58
|
+
def self.with_authentication(server_context, required_scope: nil, resource: nil)
|
|
56
59
|
config = config_from(server_context)
|
|
57
60
|
introspection = McpToolkit::Auth::Introspection.call(server_context[:bearer_token], config:)
|
|
58
61
|
return error_response("Unauthorized: invalid or expired token") unless introspection.valid?
|
|
@@ -61,11 +64,28 @@ class McpToolkit::Tools::Base < MCP::Tool
|
|
|
61
64
|
return error_response("Unauthorized: token lacks the #{required_scope.inspect} scope")
|
|
62
65
|
end
|
|
63
66
|
|
|
64
|
-
|
|
67
|
+
superuser_refusal = superuser_only_refusal(resource, introspection)
|
|
68
|
+
return superuser_refusal if superuser_refusal
|
|
69
|
+
|
|
70
|
+
# Yields the introspection so a discovery tool (`resources`) can HIDE
|
|
71
|
+
# superuser-only resources from a non-superuser caller (get/list/resource_schema
|
|
72
|
+
# instead pass `resource:` above to REFUSE a specific one).
|
|
73
|
+
text_response(yield(introspection))
|
|
65
74
|
rescue McpToolkit::Errors::InvalidParams => e
|
|
66
75
|
error_response("Invalid request: #{e.message}")
|
|
67
76
|
end
|
|
68
77
|
|
|
78
|
+
# Refuses a superuser-only resource for a non-superuser caller (get / list /
|
|
79
|
+
# resource_schema); nil = allowed. Mirrors the authority path's
|
|
80
|
+
# `ensure_resource_accessible!`. `resources` HIDES such resources instead of
|
|
81
|
+
# refusing — it filters on `introspection.superuser?` directly.
|
|
82
|
+
def self.superuser_only_refusal(resource, introspection)
|
|
83
|
+
return nil unless resource&.superusers_only?
|
|
84
|
+
return nil if introspection.superuser?
|
|
85
|
+
|
|
86
|
+
error_response("Unauthorized: #{resource.name} is restricted to superuser (user-scoped) MCP tokens")
|
|
87
|
+
end
|
|
88
|
+
|
|
69
89
|
def self.text_response(payload)
|
|
70
90
|
text = payload.is_a?(String) ? payload : JSON.generate(payload)
|
|
71
91
|
MCP::Tool::Response.new([{ type: "text", text: }])
|
|
@@ -48,7 +48,7 @@ class McpToolkit::Tools::Get < McpToolkit::Tools::Base
|
|
|
48
48
|
# the scope check (and so an unknown resource is a clean tool error).
|
|
49
49
|
descriptor = resolve_descriptor(resource, config)
|
|
50
50
|
required_scope = config.registry.required_scope_for(descriptor)
|
|
51
|
-
with_account(server_context, account_id:, required_scope:) do |scope_root|
|
|
51
|
+
with_account(server_context, account_id:, required_scope:, resource: descriptor) do |scope_root|
|
|
52
52
|
McpToolkit::GetExecutor.call(resource: descriptor, scope_root:, id:, fields:)
|
|
53
53
|
end
|
|
54
54
|
rescue McpToolkit::Errors::InvalidParams => e
|
|
@@ -15,11 +15,26 @@ class McpToolkit::Tools::List < McpToolkit::Tools::Base
|
|
|
15
15
|
- limit: page size (default 25, max 100)
|
|
16
16
|
- offset: pagination offset (default 0)
|
|
17
17
|
|
|
18
|
-
Per-attribute
|
|
19
|
-
- filter: an object of { <key>: <value> }
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
Per-attribute filters:
|
|
19
|
+
- filter: an object of { <key>: <value> } filters, applied ON TOP of the account scope
|
|
20
|
+
(they can only narrow, never widen). Each resource advertises its available filter keys
|
|
21
|
+
and operators via `resource_schema`. Unknown keys are rejected.
|
|
22
|
+
- A bare value matches by equality. A comma-separated string or an array of scalars
|
|
23
|
+
matches ANY of the values (IN), e.g. { "status": "booked,canceled" } or
|
|
24
|
+
{ "status": ["booked", "canceled"] }. The string "null" (or a JSON null) matches
|
|
25
|
+
records where the value is NULL.
|
|
26
|
+
- An operator condition is an object { "op": <operator>, "value": <value> }, e.g.
|
|
27
|
+
{ "price": { "op": "gteq", "value": 100 } }. An array of conditions ANDs them into a
|
|
28
|
+
range: { "price": [{ "op": "gteq", "value": 100 }, { "op": "lt", "value": 200 }] }.
|
|
29
|
+
Each attribute's supported operators are listed in `resource_schema`.
|
|
30
|
+
- Some filter keys require a companion key (e.g. a polymorphic id and its type) —
|
|
31
|
+
`resource_schema` advertises these under a relationship's `filter.requires`; pass
|
|
32
|
+
both keys together.
|
|
33
|
+
|
|
34
|
+
Resource-specific filters:
|
|
35
|
+
- Some resources accept additional filters advertised in `resource_schema` under
|
|
36
|
+
`resource_filters`. Pass each as a TOP-LEVEL argument (NOT inside `filter`), e.g.
|
|
37
|
+
{ "resource": "...", "<name>": <value> }.
|
|
23
38
|
|
|
24
39
|
Sparse fieldset:
|
|
25
40
|
- fields: names of the attributes and/or relationships to include in each record, as an
|
|
@@ -50,8 +65,8 @@ class McpToolkit::Tools::List < McpToolkit::Tools::Base
|
|
|
50
65
|
},
|
|
51
66
|
filter: {
|
|
52
67
|
type: "object",
|
|
53
|
-
description: "Per-attribute
|
|
54
|
-
"
|
|
68
|
+
description: "Per-attribute filters, e.g. { \"booking_id\": 42 }. See a resource's " \
|
|
69
|
+
"`resource_schema` `filters` for the keys and operators it accepts.",
|
|
55
70
|
additionalProperties: true
|
|
56
71
|
},
|
|
57
72
|
limit: { type: "integer", description: "Page size (default 25, max 100)" },
|
|
@@ -65,7 +80,10 @@ class McpToolkit::Tools::List < McpToolkit::Tools::Base
|
|
|
65
80
|
"resource's `resource_schema` for valid attribute and relationship names."
|
|
66
81
|
}
|
|
67
82
|
},
|
|
68
|
-
required: ["resource"]
|
|
83
|
+
required: ["resource"],
|
|
84
|
+
# Resource-specific filters (resource_schema's `resource_filters`) arrive as
|
|
85
|
+
# top-level arguments, so the schema must not advertise a closed shape.
|
|
86
|
+
additionalProperties: true
|
|
69
87
|
)
|
|
70
88
|
|
|
71
89
|
def self.call(server_context:, resource: nil, account_id: nil, **params)
|
|
@@ -74,7 +92,7 @@ class McpToolkit::Tools::List < McpToolkit::Tools::Base
|
|
|
74
92
|
# the scope check (and so an unknown resource is a clean tool error).
|
|
75
93
|
descriptor = resolve_descriptor(resource, config)
|
|
76
94
|
required_scope = config.registry.required_scope_for(descriptor)
|
|
77
|
-
with_account(server_context, account_id:, required_scope:) do |scope_root|
|
|
95
|
+
with_account(server_context, account_id:, required_scope:, resource: descriptor) do |scope_root|
|
|
78
96
|
McpToolkit::ListExecutor.call(resource: descriptor, scope_root:, params:)
|
|
79
97
|
end
|
|
80
98
|
rescue McpToolkit::Errors::InvalidParams => e
|
|
@@ -7,11 +7,19 @@ class McpToolkit::Tools::ResourceSchema < McpToolkit::Tools::Base
|
|
|
7
7
|
description <<~DESC.strip
|
|
8
8
|
Describe a single read-only resource in detail. Pass the resource name as `resource` (use
|
|
9
9
|
the `resources` tool to discover names). Returns:
|
|
10
|
-
- attributes: every field in the response, each with its `type
|
|
10
|
+
- attributes: every field in the response, each with its `type`, a value `format` hint,
|
|
11
|
+
whether it is `filterable`, and the filter `operators` it accepts
|
|
11
12
|
- relationships: associated resources emitted in the record's `links`; each names the
|
|
12
13
|
`target_resource` it resolves to (callable via `list`/`get`)
|
|
13
14
|
- standard_filters: ids, updated_since, limit, offset (accepted by the `list` tool)
|
|
14
|
-
- filters: the per-attribute equality filter keys the `list` tool accepts
|
|
15
|
+
- filters: the per-attribute equality/operator filter keys the `list` tool accepts in
|
|
16
|
+
its `filter` argument
|
|
17
|
+
- resource_filters: resource-specific filters, if any — each is passed as a TOP-LEVEL
|
|
18
|
+
argument of the `list` tool (NOT inside `filter`), e.g. { "resource": "...",
|
|
19
|
+
"<name>": <value> }
|
|
20
|
+
- filter_examples: ready-to-use `filter` payloads for this resource
|
|
21
|
+
A relationship's `filter` block lists the keys that filter by it; when it names a
|
|
22
|
+
`requires` key (e.g. a polymorphic id needing its type), pass BOTH keys together.
|
|
15
23
|
The `attributes` and `relationships` names are also the valid values for the `fields` sparse
|
|
16
24
|
fieldset argument on `get` / `list`. Call this before `list` to learn a resource's shape.
|
|
17
25
|
DESC
|
|
@@ -31,7 +39,8 @@ class McpToolkit::Tools::ResourceSchema < McpToolkit::Tools::Base
|
|
|
31
39
|
# Resolve the resource FIRST so its effective required scope gates discovery
|
|
32
40
|
# of THIS resource's shape (and an unknown resource is a clean tool error).
|
|
33
41
|
descriptor = resolve_descriptor(resource, config)
|
|
34
|
-
with_authentication(server_context, required_scope: config.registry.required_scope_for(descriptor)
|
|
42
|
+
with_authentication(server_context, required_scope: config.registry.required_scope_for(descriptor),
|
|
43
|
+
resource: descriptor) do
|
|
35
44
|
McpToolkit::ResourceSchema.call(descriptor, registry: config.registry)
|
|
36
45
|
end
|
|
37
46
|
rescue McpToolkit::Errors::InvalidParams => e
|
|
@@ -5,9 +5,10 @@ class McpToolkit::Tools::Resources < McpToolkit::Tools::Base
|
|
|
5
5
|
tool_name "resources"
|
|
6
6
|
description <<~DESC.strip
|
|
7
7
|
List all read-only resources available via the `list` and `get` tools. Returns each
|
|
8
|
-
resource's name
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
resource's name, a short description, whether it accepts filters (`filterable`) and — when
|
|
9
|
+
present — a usage `note` (read it before interpreting the resource's data). Call this once
|
|
10
|
+
at the start of a session to learn what exists, then use `resource_schema` for a specific
|
|
11
|
+
resource's attributes, relationships and filters.
|
|
11
12
|
DESC
|
|
12
13
|
|
|
13
14
|
input_schema(properties: {})
|
|
@@ -16,12 +17,34 @@ class McpToolkit::Tools::Resources < McpToolkit::Tools::Base
|
|
|
16
17
|
config = config_from(server_context)
|
|
17
18
|
# Discovery requires the registry-level default scope (the satellite's
|
|
18
19
|
# app-wide scope); per-resource scopes are enforced by `get` / `list`.
|
|
19
|
-
with_authentication(server_context,
|
|
20
|
+
with_authentication(server_context,
|
|
21
|
+
required_scope: config.registry.default_required_permissions_scope) do |introspection|
|
|
20
22
|
{
|
|
21
|
-
resources: config.registry.resources
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
resources: config.registry.resources
|
|
24
|
+
.reject { |resource| resource.superusers_only? && !introspection.superuser? }
|
|
25
|
+
.map do |resource|
|
|
26
|
+
{
|
|
27
|
+
name: resource.name,
|
|
28
|
+
description: resource.description,
|
|
29
|
+
filterable: filterable?(resource, config),
|
|
30
|
+
note: resource.note
|
|
31
|
+
}.compact
|
|
32
|
+
end
|
|
24
33
|
}
|
|
25
34
|
end
|
|
26
35
|
end
|
|
36
|
+
|
|
37
|
+
# Whether the resource can be filtered at all — via the generic allowlist OR a
|
|
38
|
+
# resource-specific custom filter. Mirrors the authority-path resources tool:
|
|
39
|
+
# resolving a lazily-declared allowlist may run host code, and one resource's
|
|
40
|
+
# failing resolution must not take down the whole discovery index, so a raise
|
|
41
|
+
# degrades to nil (the key is omitted) and the source is retried on the next
|
|
42
|
+
# read (see Resource#filterable).
|
|
43
|
+
def self.filterable?(resource, config)
|
|
44
|
+
resource.filterable_columns.any? || resource.custom_filters.any?
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
config.logger&.warn("mcp_toolkit: filterable resolution failed for #{resource.name}: #{e.message}")
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
private_class_method :filterable?
|
|
27
50
|
end
|
|
@@ -5,7 +5,7 @@ require "logger"
|
|
|
5
5
|
# The MCP Streamable-HTTP transport, provided as an includable concern. An
|
|
6
6
|
# app's controller includes this to get the full transport with no per-app code:
|
|
7
7
|
#
|
|
8
|
-
# class
|
|
8
|
+
# class McpController < ApplicationController
|
|
9
9
|
# include McpToolkit::Transport::ControllerMethods
|
|
10
10
|
# end
|
|
11
11
|
#
|
|
@@ -44,7 +44,7 @@ require "logger"
|
|
|
44
44
|
#
|
|
45
45
|
# CSRF: the concern disables forgery protection (this is a token-authenticated
|
|
46
46
|
# JSON API). Inherit from ActionController::Base (not ::API) if your app's
|
|
47
|
-
# controller stack needs helper_method
|
|
47
|
+
# controller stack needs helper_method (as some host controller stacks require).
|
|
48
48
|
module McpToolkit::Transport::ControllerMethods
|
|
49
49
|
extend ActiveSupport::Concern
|
|
50
50
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generic, api-agnostic usage metering for the AUTHORITY transport.
|
|
4
|
+
#
|
|
5
|
+
# Wired to the authority controller's billing hooks purely through config, so a
|
|
6
|
+
# host meters MCP traffic WITHOUT subclassing or customizing the controller:
|
|
7
|
+
#
|
|
8
|
+
# meter = McpToolkit::UsageMetering::Recorder.new(
|
|
9
|
+
# event_builder: ->(request_data:, params:, arguments:, scrubbed_arguments:, account:, principal:) { {...} },
|
|
10
|
+
# sink: ->(events) { MyLedger.insert_all(events) },
|
|
11
|
+
# parameter_filter: ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters),
|
|
12
|
+
# logger: Rails.logger,
|
|
13
|
+
# error_reporter: ->(e) { Sentry.capture_exception(e) }
|
|
14
|
+
# )
|
|
15
|
+
# config.usage_recorder = meter.method(:record) # per JSON-RPC call
|
|
16
|
+
# config.usage_flusher = meter.method(:flush) # after the response
|
|
17
|
+
#
|
|
18
|
+
# One event is accumulated per BILLABLE JSON-RPC call (default: `tools/call`) and
|
|
19
|
+
# all of a request's events are flushed together after the response. Because the
|
|
20
|
+
# authority transport re-resolves the account per batch element and calls `record`
|
|
21
|
+
# per element, a mixed-account batch meters each call against its own account.
|
|
22
|
+
#
|
|
23
|
+
# Two invariants:
|
|
24
|
+
# * Metering NEVER affects the MCP response — every error here is logged (via
|
|
25
|
+
# `logger`) and reported (via `error_reporter`), then swallowed.
|
|
26
|
+
# * The raw arguments are scrubbed through `parameter_filter` BEFORE they reach
|
|
27
|
+
# the event, so a filtered key (e.g. a token) never leaves this object.
|
|
28
|
+
#
|
|
29
|
+
# The `event_builder` returns ONE ledger row's attributes (a Hash) for a call, or
|
|
30
|
+
# nil to skip it; the gem stays app-agnostic by never naming the row's columns. The
|
|
31
|
+
# `sink` persists the accumulated array in one shot.
|
|
32
|
+
#
|
|
33
|
+
# Per-request state (the accumulation buffer) lives on the Rack request env, so the
|
|
34
|
+
# Recorder itself is stateless and safe to share across requests/threads.
|
|
35
|
+
class McpToolkit::UsageMetering::Recorder
|
|
36
|
+
DEFAULT_BILLABLE_METHODS = %w[tools/call].freeze
|
|
37
|
+
BUFFER_ENV_KEY = "mcp_toolkit.usage_events"
|
|
38
|
+
|
|
39
|
+
def initialize(event_builder:, sink:, parameter_filter: nil,
|
|
40
|
+
billable_methods: DEFAULT_BILLABLE_METHODS, logger: nil, error_reporter: nil)
|
|
41
|
+
@event_builder = event_builder
|
|
42
|
+
@sink = sink
|
|
43
|
+
@parameter_filter = parameter_filter
|
|
44
|
+
@billable_methods = billable_methods
|
|
45
|
+
@logger = logger
|
|
46
|
+
@error_reporter = error_reporter
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# `config.usage_recorder` target. Accumulates one event for a billable call onto
|
|
50
|
+
# the current request's buffer. Non-billable methods (ping, initialize,
|
|
51
|
+
# tools/list, ...) are ignored; a nil event from the builder is skipped.
|
|
52
|
+
def record(request_data:, account:, principal:, controller:)
|
|
53
|
+
return unless request_data.is_a?(Hash)
|
|
54
|
+
return unless @billable_methods.include?(request_data["method"])
|
|
55
|
+
|
|
56
|
+
params = request_data["params"].to_h
|
|
57
|
+
arguments = params["arguments"].to_h
|
|
58
|
+
event = @event_builder.call(
|
|
59
|
+
request_data:, params:, arguments:,
|
|
60
|
+
scrubbed_arguments: scrub(arguments), account:, principal:
|
|
61
|
+
)
|
|
62
|
+
buffer_for(controller) << event unless event.nil?
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
report("failed to accumulate event", e)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# `config.usage_flusher` target. Persists the request's accumulated events via the
|
|
68
|
+
# sink in one shot. No-op when nothing was accumulated. If the batch write fails,
|
|
69
|
+
# falls back to per-event writes so one un-persistable ("poison") event can't drop
|
|
70
|
+
# metering for the whole request.
|
|
71
|
+
def flush(controller:)
|
|
72
|
+
events = buffer_for(controller)
|
|
73
|
+
return if events.empty?
|
|
74
|
+
|
|
75
|
+
@sink.call(events)
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
flush_individually(events, e)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# The batch write failed. Retry each event on its own so a single un-persistable
|
|
83
|
+
# event (a constraint violation, bad encoding) loses ONLY itself instead of
|
|
84
|
+
# dropping every sibling call's metering — which a caller could otherwise
|
|
85
|
+
# exploit to evade billing for a whole batch by appending one poison call.
|
|
86
|
+
# Assumes the batch sink is atomic (nothing persisted on failure — true for
|
|
87
|
+
# `insert_all`); a non-atomic sink could double-write the events that did land,
|
|
88
|
+
# so this stays a fallback, not the default path.
|
|
89
|
+
def flush_individually(events, batch_error)
|
|
90
|
+
@logger&.warn(
|
|
91
|
+
"MCP usage tracking: batch flush of #{events.size} event(s) failed " \
|
|
92
|
+
"(#{batch_error.message}), retrying individually"
|
|
93
|
+
)
|
|
94
|
+
events.each do |event|
|
|
95
|
+
@sink.call([event])
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
report("dropped 1 unpersistable usage event", e)
|
|
98
|
+
end
|
|
99
|
+
rescue StandardError
|
|
100
|
+
# Metering MUST NEVER affect the MCP response. `flush` already runs this from
|
|
101
|
+
# its rescue, so a raise here (e.g. a misbehaving logger or error_reporter)
|
|
102
|
+
# would escape into the after_action. Swallow it as the last resort.
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def scrub(arguments)
|
|
107
|
+
hash = arguments.to_h
|
|
108
|
+
@parameter_filter ? @parameter_filter.filter(hash) : hash
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Per-request accumulation buffer, stored on the Rack env so the Recorder holds
|
|
112
|
+
# no per-request state of its own (thread-safe to share across requests).
|
|
113
|
+
def buffer_for(controller)
|
|
114
|
+
controller.request.env[BUFFER_ENV_KEY] ||= []
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def report(message, error)
|
|
118
|
+
@logger&.warn("MCP usage tracking: #{message}: #{error.message}")
|
|
119
|
+
@error_reporter&.call(error)
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/mcp_toolkit/version.rb
CHANGED
data/lib/mcp_toolkit.rb
CHANGED
|
@@ -15,9 +15,12 @@ require "zeitwerk"
|
|
|
15
15
|
# active_support/concern - Transport::ControllerMethods is an includable concern
|
|
16
16
|
# active_support/cache - the default MemoryStore cache_store
|
|
17
17
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# connection
|
|
18
|
+
# Two third-party libs are the exception to the centralize-here rule: each is
|
|
19
|
+
# required alongside its owner file rather than up front.
|
|
20
|
+
# faraday - the HTTP client, required by the objects that build a connection
|
|
21
|
+
# (Auth::AuthorityServerClient and Gateway::Client).
|
|
22
|
+
# concurrent - concurrent-ruby's futures, required by its sole owner
|
|
23
|
+
# Gateway::Aggregator (which pulls upstreams in parallel).
|
|
21
24
|
require "json"
|
|
22
25
|
require "digest"
|
|
23
26
|
require "time"
|
|
@@ -63,8 +66,18 @@ loader.ignore("#{__dir__}/mcp_toolkit/version.rb")
|
|
|
63
66
|
# under `app/controllers` (outside this lib-rooted loader), so it needs no ignore;
|
|
64
67
|
# it is loaded by Rails' autoloader via the engine when mounted.
|
|
65
68
|
loader.ignore("#{__dir__}/mcp_toolkit/engine.rb")
|
|
69
|
+
# `engine_controllers.rb` reopens `McpToolkit` to add the lazy `parent_controller`
|
|
70
|
+
# builder + `const_missing` (it defines module methods, not a file-named
|
|
71
|
+
# constant), so it is required explicitly below rather than autoloaded.
|
|
72
|
+
loader.ignore("#{__dir__}/mcp_toolkit/engine_controllers.rb")
|
|
66
73
|
loader.setup
|
|
67
74
|
|
|
75
|
+
# The lazy `parent_controller` builder (McpToolkit.build_engine_controllers! /
|
|
76
|
+
# reset_engine_controllers! / const_missing). Required unconditionally (it names no
|
|
77
|
+
# Rails constant at load time); it only builds controllers when one is referenced,
|
|
78
|
+
# which happens only under Rails.
|
|
79
|
+
require_relative "mcp_toolkit/engine_controllers"
|
|
80
|
+
|
|
68
81
|
# The toolkit for building account-scoped, read-only MCP servers on top of the
|
|
69
82
|
# official `mcp` gem. See README.md for the satellite + authority quickstarts.
|
|
70
83
|
#
|