mcp_toolkit 0.3.0 → 0.4.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 +201 -2
- data/README.md +315 -34
- data/config/routes.rb +19 -4
- 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 +408 -0
- data/lib/mcp_toolkit/authority/registry_tool_provider.rb +70 -0
- data/lib/mcp_toolkit/authority/token.rb +124 -0
- data/lib/mcp_toolkit/authority/tools/base.rb +122 -0
- data/lib/mcp_toolkit/authority/tools/get.rb +58 -0
- data/lib/mcp_toolkit/authority/tools/list.rb +84 -0
- data/lib/mcp_toolkit/authority/tools/resource_schema.rb +44 -0
- data/lib/mcp_toolkit/authority/tools/resources.rb +33 -0
- data/lib/mcp_toolkit/authority.rb +24 -0
- data/lib/mcp_toolkit/configuration.rb +205 -2
- data/lib/mcp_toolkit/dispatcher.rb +234 -0
- data/lib/mcp_toolkit/engine.rb +22 -7
- data/lib/mcp_toolkit/engine_controllers.rb +116 -0
- data/lib/mcp_toolkit/gateway/aggregator.rb +122 -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 +17 -0
- 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 +125 -7
- data/lib/mcp_toolkit/resource_schema.rb +14 -1
- data/lib/mcp_toolkit/serializer/base.rb +2 -2
- data/lib/mcp_toolkit/session.rb +17 -9
- data/lib/mcp_toolkit/tools/authority_base.rb +127 -0
- data/lib/mcp_toolkit/transport/controller_methods.rb +2 -2
- data/lib/mcp_toolkit/usage_metering/recorder.rb +95 -0
- data/lib/mcp_toolkit/version.rb +1 -1
- data/lib/mcp_toolkit.rb +16 -3
- metadata +38 -2
- data/app/controllers/mcp_toolkit/server_controller.rb +0 -19
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared base for the four GENERIC, Registry-backed authority tools
|
|
4
|
+
# (McpToolkit::Authority::Tools::{Resources,ResourceSchema,Get,List}) served
|
|
5
|
+
# through McpToolkit::Authority::RegistryToolProvider on the hand-rolled
|
|
6
|
+
# authority dispatch path.
|
|
7
|
+
#
|
|
8
|
+
# Unlike the satellite tools (McpToolkit::Tools::*, which subclass the SDK's
|
|
9
|
+
# MCP::Tool, self-authenticate, and return an MCP::Tool::Response), these are
|
|
10
|
+
# plain objects satisfying the dispatcher's duck-typed tool contract:
|
|
11
|
+
#
|
|
12
|
+
# tool.required_permissions_scope -> nil (see below — no STATIC scope)
|
|
13
|
+
# tool.call(context:, **arguments) -> Hash (the dispatcher wraps it into
|
|
14
|
+
# { content: [{ type: "text", ... }] })
|
|
15
|
+
#
|
|
16
|
+
# The scope a caller needs is DYNAMIC — it depends on which `resource` argument
|
|
17
|
+
# was passed — so these tools declare no static scope and instead enforce the
|
|
18
|
+
# resolved resource's `required_scope_for` INSIDE #call (see #ensure_scope!).
|
|
19
|
+
# The `context` (McpToolkit::Authority::Context) supplies the resolved account,
|
|
20
|
+
# the principal, and the derived superuser flag.
|
|
21
|
+
#
|
|
22
|
+
# The tools reuse the existing executors / schema builder UNCHANGED; this base
|
|
23
|
+
# only holds the resolution + gating every one of them repeats: resolve the
|
|
24
|
+
# resource descriptor, gate a superuser-only resource, gate the per-resource
|
|
25
|
+
# scope, and (for get/list) require a selected account.
|
|
26
|
+
class McpToolkit::Authority::Tools::Base
|
|
27
|
+
class << self
|
|
28
|
+
attr_reader :_description, :_input_schema
|
|
29
|
+
|
|
30
|
+
def tool_name(name = nil)
|
|
31
|
+
@_tool_name = name.to_s if name
|
|
32
|
+
@_tool_name
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def description(text = nil)
|
|
36
|
+
@_description = text if text
|
|
37
|
+
@_description
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def input_schema(schema = nil)
|
|
41
|
+
@_input_schema = schema if schema
|
|
42
|
+
@_input_schema || { type: "object", properties: {} }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The static tool definition returned by the provider's `tool_definitions`
|
|
46
|
+
# (part of `tools/list`). Generic and context-independent.
|
|
47
|
+
def definition
|
|
48
|
+
{ name: tool_name, description: _description, inputSchema: input_schema }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
attr_reader :config
|
|
53
|
+
|
|
54
|
+
def initialize(config:)
|
|
55
|
+
@config = config
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The dispatcher's central scope gate reads this; nil = no STATIC scope. The
|
|
59
|
+
# real, per-resource scope is enforced dynamically in #ensure_scope! (the scope
|
|
60
|
+
# depends on the `resource` argument, unknown until #call).
|
|
61
|
+
def required_permissions_scope
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def registry
|
|
68
|
+
config.registry
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Resolves the `resource` argument to a registered descriptor, raising the
|
|
72
|
+
# protocol InvalidParams (=> JSON-RPC -32602) for a blank or unknown name so the
|
|
73
|
+
# dispatcher renders a clean top-level error.
|
|
74
|
+
def resolve_descriptor(name)
|
|
75
|
+
raise McpToolkit::Protocol::InvalidParams, "resource is required" if name.to_s.strip.empty?
|
|
76
|
+
|
|
77
|
+
registry.fetch(name)
|
|
78
|
+
rescue McpToolkit::Registry::UnknownResource => e
|
|
79
|
+
raise McpToolkit::Protocol::InvalidParams, e.message
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Refuses a superuser-only resource for a non-superuser caller (get / list /
|
|
83
|
+
# resource_schema). `resources` HIDES such resources instead — see that tool.
|
|
84
|
+
def ensure_resource_accessible!(descriptor, context)
|
|
85
|
+
return unless descriptor.superusers_only?
|
|
86
|
+
return if context.superuser?
|
|
87
|
+
|
|
88
|
+
raise McpToolkit::Protocol::InvalidRequest,
|
|
89
|
+
"#{descriptor.name} is restricted to superuser (user-scoped) MCP tokens"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Enforces the resource's effective required scope against the principal. Blank
|
|
93
|
+
# scope => no check. Mirrors the dispatcher's central gate error shape
|
|
94
|
+
# (InvalidRequest), keeping scope refusals byte-consistent across host tools.
|
|
95
|
+
def ensure_scope!(descriptor, context)
|
|
96
|
+
required = registry.required_scope_for(descriptor)
|
|
97
|
+
return if required.to_s.empty?
|
|
98
|
+
return if context.principal&.authorized_for_scope?(required)
|
|
99
|
+
|
|
100
|
+
raise McpToolkit::Protocol::InvalidRequest, "This token lacks the #{required.inspect} scope"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# get / list read tenant data, so they REQUIRE a resolved account: a superuser
|
|
104
|
+
# token that selected none would otherwise reach `scope.call(nil)` and leak
|
|
105
|
+
# across tenants. resource_schema / resources (shape only) do not call this.
|
|
106
|
+
def ensure_account!(context)
|
|
107
|
+
return if context.account
|
|
108
|
+
|
|
109
|
+
raise McpToolkit::Protocol::InvalidParams,
|
|
110
|
+
"an account must be selected (pass account_id) to read this resource"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Runs an executor, translating a data-layer McpToolkit::Errors::InvalidParams
|
|
114
|
+
# (bad id, unknown filter/field key, ...) into the protocol InvalidParams the
|
|
115
|
+
# dispatcher renders as JSON-RPC -32602 (rather than letting it fall through to
|
|
116
|
+
# the dispatcher's generic -32603 internal-error mapping).
|
|
117
|
+
def run_executor
|
|
118
|
+
yield
|
|
119
|
+
rescue McpToolkit::Errors::InvalidParams => e
|
|
120
|
+
raise McpToolkit::Protocol::InvalidParams, e.message
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Authority-path tool: fetch a single record by id from a registered resource,
|
|
4
|
+
# scoped to the caller's resolved account. Gates a superuser-only resource, the
|
|
5
|
+
# resource's required scope, and requires a selected account before reading.
|
|
6
|
+
class McpToolkit::Authority::Tools::Get < McpToolkit::Authority::Tools::Base
|
|
7
|
+
tool_name "get"
|
|
8
|
+
description <<~DESC.strip
|
|
9
|
+
Fetch a single record by id from a read-only resource. Pass the resource name as `resource`
|
|
10
|
+
and the record id as `id`. Use the `resources` tool to discover available resources.
|
|
11
|
+
|
|
12
|
+
For tokens that span multiple accounts (superuser), pass `account_id` to pin the active
|
|
13
|
+
account; account-scoped tokens may omit it. The response mirrors the resource's record
|
|
14
|
+
shape (attributes + a `links` block).
|
|
15
|
+
|
|
16
|
+
Pass `fields` to return a sparse fieldset — the attributes and/or relationships you name
|
|
17
|
+
(as an array or comma-separated string), omitting everything else. Include "id" if you need
|
|
18
|
+
it. Valid names come from the resource's `resource_schema`; unknown names are rejected.
|
|
19
|
+
DESC
|
|
20
|
+
|
|
21
|
+
input_schema(
|
|
22
|
+
{
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
resource: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Resource name (use the `resources` tool to discover valid values)"
|
|
28
|
+
},
|
|
29
|
+
# The id type is left open so a string/UUID primary key works as well as an
|
|
30
|
+
# integer one; the record is looked up by the value as given, uncoerced.
|
|
31
|
+
id: { type: %w[string integer], description: "The record ID (integer or string/UUID)" },
|
|
32
|
+
account_id: {
|
|
33
|
+
type: "integer",
|
|
34
|
+
description: "Account to operate on. Required for superuser tokens; ignored otherwise."
|
|
35
|
+
},
|
|
36
|
+
fields: {
|
|
37
|
+
type: %w[array string],
|
|
38
|
+
items: { type: "string" },
|
|
39
|
+
description: "Sparse fieldset — names of attributes and/or relationships to include, as " \
|
|
40
|
+
"an array or a comma-separated string. Omit to return every field. Include " \
|
|
41
|
+
"\"id\" if you need it. Unknown names are rejected."
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
required: %w[resource id]
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def call(context:, resource: nil, id: nil, fields: nil, **_args)
|
|
49
|
+
descriptor = resolve_descriptor(resource)
|
|
50
|
+
ensure_resource_accessible!(descriptor, context)
|
|
51
|
+
ensure_scope!(descriptor, context)
|
|
52
|
+
ensure_account!(context)
|
|
53
|
+
|
|
54
|
+
run_executor do
|
|
55
|
+
McpToolkit::GetExecutor.call(resource: descriptor, scope_root: context.account, id:, fields:)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Authority-path tool: fetch a paginated list of records from a registered
|
|
4
|
+
# resource, scoped to the caller's resolved account. Gates a superuser-only
|
|
5
|
+
# resource, the resource's required scope, and requires a selected account before
|
|
6
|
+
# reading. Standard filters, per-attribute equality/operator filters, resource
|
|
7
|
+
# custom filters, pagination, and sparse fieldsets are all handled by the reused
|
|
8
|
+
# McpToolkit::ListExecutor.
|
|
9
|
+
class McpToolkit::Authority::Tools::List < McpToolkit::Authority::Tools::Base
|
|
10
|
+
tool_name "list"
|
|
11
|
+
description <<~DESC.strip
|
|
12
|
+
Fetch a paginated list of records from a read-only resource. Pass the resource name as
|
|
13
|
+
`resource`. Use the `resources` tool to discover resources and `resource_schema` to learn a
|
|
14
|
+
resource's shape.
|
|
15
|
+
|
|
16
|
+
Standard filters:
|
|
17
|
+
- ids: comma-separated list of IDs to fetch
|
|
18
|
+
- updated_since: ISO 8601 timestamp; only records updated after this time
|
|
19
|
+
- limit: page size (default 25, max 100)
|
|
20
|
+
- offset: pagination offset (default 0)
|
|
21
|
+
|
|
22
|
+
Per-attribute filters:
|
|
23
|
+
- filter: an object of { <key>: <value> } filters, applied ON TOP of the account scope
|
|
24
|
+
(they can only narrow, never widen). Each resource advertises its available filter keys
|
|
25
|
+
and operators via `resource_schema`. Unknown keys are rejected.
|
|
26
|
+
|
|
27
|
+
Sparse fieldset:
|
|
28
|
+
- fields: names of the attributes and/or relationships to include in each record, as an
|
|
29
|
+
array or a comma-separated string. Omit to return every field. Include "id" if you need
|
|
30
|
+
it. Valid names come from a resource's `resource_schema`; unknown names are rejected.
|
|
31
|
+
|
|
32
|
+
For tokens that span multiple accounts (superuser), pass `account_id` to pin the active
|
|
33
|
+
account; account-scoped tokens may omit it. The response shape is
|
|
34
|
+
{ "<resource>": [...], "meta": { total_count, limit, offset } }.
|
|
35
|
+
DESC
|
|
36
|
+
|
|
37
|
+
input_schema(
|
|
38
|
+
{
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {
|
|
41
|
+
resource: {
|
|
42
|
+
type: "string",
|
|
43
|
+
description: "Resource name (use the `resources` tool to discover valid values)"
|
|
44
|
+
},
|
|
45
|
+
account_id: {
|
|
46
|
+
type: "integer",
|
|
47
|
+
description: "Account to operate on. Required for superuser tokens; ignored otherwise."
|
|
48
|
+
},
|
|
49
|
+
ids: { type: "string", description: "Comma-separated list of IDs to fetch" },
|
|
50
|
+
updated_since: {
|
|
51
|
+
type: "string",
|
|
52
|
+
description: "ISO 8601 timestamp; only records updated after this time"
|
|
53
|
+
},
|
|
54
|
+
filter: {
|
|
55
|
+
type: "object",
|
|
56
|
+
description: "Per-attribute filters, e.g. { \"booking_id\": 42 }. See a resource's " \
|
|
57
|
+
"`resource_schema` `filters` for the keys and operators it accepts.",
|
|
58
|
+
additionalProperties: true
|
|
59
|
+
},
|
|
60
|
+
limit: { type: "integer", description: "Page size (default 25, max 100)" },
|
|
61
|
+
offset: { type: "integer", description: "Pagination offset (default 0)" },
|
|
62
|
+
fields: {
|
|
63
|
+
type: %w[array string],
|
|
64
|
+
items: { type: "string" },
|
|
65
|
+
description: "Sparse fieldset — names of attributes and/or relationships to include in " \
|
|
66
|
+
"each record, as an array or a comma-separated string. Omit to return every " \
|
|
67
|
+
"field. Include \"id\" if you need it. Unknown names are rejected."
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
required: ["resource"]
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def call(context:, resource: nil, **params)
|
|
75
|
+
descriptor = resolve_descriptor(resource)
|
|
76
|
+
ensure_resource_accessible!(descriptor, context)
|
|
77
|
+
ensure_scope!(descriptor, context)
|
|
78
|
+
ensure_account!(context)
|
|
79
|
+
|
|
80
|
+
run_executor do
|
|
81
|
+
McpToolkit::ListExecutor.call(resource: descriptor, scope_root: context.account, params:)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Authority-path discovery tool: the detailed schema (attributes with types +
|
|
4
|
+
# filter operators, relationships, filters, note) of one registered resource.
|
|
5
|
+
#
|
|
6
|
+
# Reveals shape, not tenant data, so it does NOT require a selected account — but
|
|
7
|
+
# it still gates a superuser-only resource (refuse) and the resource's required
|
|
8
|
+
# scope, so a caller can't discover the shape of something it can't read.
|
|
9
|
+
class McpToolkit::Authority::Tools::ResourceSchema < McpToolkit::Authority::Tools::Base
|
|
10
|
+
tool_name "resource_schema"
|
|
11
|
+
description <<~DESC.strip
|
|
12
|
+
Describe a single read-only resource in detail. Pass the resource name as `resource` (use
|
|
13
|
+
the `resources` tool to discover names). Returns:
|
|
14
|
+
- attributes: every field in the response, each with its `type`, a value `format` hint,
|
|
15
|
+
whether it is `filterable`, and the filter `operators` it accepts
|
|
16
|
+
- relationships: associated resources emitted in the record's `links`; each names the
|
|
17
|
+
`target_resource` it resolves to (callable via `list`/`get`)
|
|
18
|
+
- standard_filters: ids, updated_since, limit, offset (accepted by the `list` tool)
|
|
19
|
+
- filters: the per-attribute equality filter keys the `list` tool accepts
|
|
20
|
+
The `attributes` and `relationships` names are also the valid values for the `fields` sparse
|
|
21
|
+
fieldset argument on `get` / `list`. Call this before `list` to learn a resource's shape.
|
|
22
|
+
DESC
|
|
23
|
+
|
|
24
|
+
input_schema(
|
|
25
|
+
{
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
resource: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Resource name (use the `resources` tool to discover valid values)"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
required: ["resource"]
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def call(context:, resource: nil, **_args)
|
|
38
|
+
descriptor = resolve_descriptor(resource)
|
|
39
|
+
ensure_resource_accessible!(descriptor, context)
|
|
40
|
+
ensure_scope!(descriptor, context)
|
|
41
|
+
|
|
42
|
+
McpToolkit::ResourceSchema.call(descriptor, registry:)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Authority-path discovery tool: lists every read-only resource registered in the
|
|
4
|
+
# config's Registry, HIDING superuser-only resources from a non-superuser caller
|
|
5
|
+
# (so they are neither advertised nor discoverable without a superuser token).
|
|
6
|
+
#
|
|
7
|
+
# The top-level list is context-independent except for that visibility filter, so
|
|
8
|
+
# the tool takes no arguments and does no per-resource scope check (per-resource
|
|
9
|
+
# scopes are enforced by `get` / `list` / `resource_schema`).
|
|
10
|
+
class McpToolkit::Authority::Tools::Resources < McpToolkit::Authority::Tools::Base
|
|
11
|
+
tool_name "resources"
|
|
12
|
+
description <<~DESC.strip
|
|
13
|
+
List all read-only resources available via the `list` and `get` tools. Returns each
|
|
14
|
+
resource's name and a short description. Call this once at the start of a session to learn
|
|
15
|
+
what exists, then use `resource_schema` for a specific resource's attributes and
|
|
16
|
+
relationships.
|
|
17
|
+
DESC
|
|
18
|
+
|
|
19
|
+
def call(context:, **_args)
|
|
20
|
+
{
|
|
21
|
+
resources: visible_resources(context).map do |resource|
|
|
22
|
+
{ name: resource.name, description: resource.description }
|
|
23
|
+
end
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Superuser-only resources are hidden from a non-superuser caller.
|
|
30
|
+
def visible_resources(context)
|
|
31
|
+
registry.resources.reject { |resource| resource.superusers_only? && !context.superuser? }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Namespace for the AUTHORITY-side building blocks: the per-request Context, the
|
|
4
|
+
# transport concern (Authority::ControllerMethods), and the lazily-parented base
|
|
5
|
+
# controller (Authority::ServerController).
|
|
6
|
+
#
|
|
7
|
+
# `ServerController` is NOT a file under this directory: like the engine's
|
|
8
|
+
# controllers, it subclasses a host controller (`config.parent_controller`) that
|
|
9
|
+
# is absent from the gem's own unit suite, so it cannot be a Zeitwerk-managed
|
|
10
|
+
# file (eager-loading it would raise). It is built on demand by
|
|
11
|
+
# `McpToolkit.build_engine_controllers!`, so this `const_missing` triggers that
|
|
12
|
+
# build the first time `McpToolkit::Authority::ServerController` is referenced
|
|
13
|
+
# (which, in a host, is after the app's initializers/to_prepare have run — so the
|
|
14
|
+
# configured parent is read at build time, not at autoload time).
|
|
15
|
+
module McpToolkit::Authority
|
|
16
|
+
def self.const_missing(name)
|
|
17
|
+
if name == :ServerController
|
|
18
|
+
McpToolkit.build_engine_controllers!
|
|
19
|
+
return const_get(name) if const_defined?(name, false)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -25,6 +25,24 @@ class McpToolkit::Configuration
|
|
|
25
25
|
# @return [String, nil] human-readable `instructions` returned on `initialize`.
|
|
26
26
|
attr_accessor :server_instructions
|
|
27
27
|
|
|
28
|
+
# --- gateway client identity (identity split) ------------------------------
|
|
29
|
+
|
|
30
|
+
# The `clientInfo` an app presents when it acts as a GATEWAY talking to its
|
|
31
|
+
# upstream MCP servers (McpToolkit::Gateway::Client's handshake). Split from the
|
|
32
|
+
# SERVER identity (`server_name`/`server_version`, advertised to the app's OWN
|
|
33
|
+
# callers) so an authority can present its real server identity downstream while
|
|
34
|
+
# keeping its upstream handshake byte-identical to a prior deployment. Both
|
|
35
|
+
# default to the server identity, so a satellite/gateway that doesn't care sets
|
|
36
|
+
# nothing.
|
|
37
|
+
#
|
|
38
|
+
# c.server_name = "acme-mcp" # advertised to our callers
|
|
39
|
+
# c.gateway_client_name = "acme-mcp-gateway" # presented to our upstreams
|
|
40
|
+
#
|
|
41
|
+
# @return [String] gateway handshake client name (defaults to `server_name`).
|
|
42
|
+
attr_writer :gateway_client_name
|
|
43
|
+
# @return [String] gateway handshake client version (defaults to `server_version`).
|
|
44
|
+
attr_writer :gateway_client_version
|
|
45
|
+
|
|
28
46
|
# --- serialization ---------------------------------------------------------
|
|
29
47
|
|
|
30
48
|
# The DEFAULT serializer base class. A `Resource` registration that does not
|
|
@@ -81,10 +99,10 @@ class McpToolkit::Configuration
|
|
|
81
99
|
|
|
82
100
|
# Looks up + verifies a plaintext bearer token locally, returning a token
|
|
83
101
|
# object (duck-typed, see below) or nil. This is the authority's
|
|
84
|
-
# `
|
|
102
|
+
# `AccessToken.authenticate(plaintext)` equivalent. Required for the :authority
|
|
85
103
|
# role; unused by a pure satellite.
|
|
86
104
|
#
|
|
87
|
-
# c.token_authenticator = ->(plaintext) {
|
|
105
|
+
# c.token_authenticator = ->(plaintext) { AccessToken.authenticate(plaintext) }
|
|
88
106
|
#
|
|
89
107
|
# The returned token object must respond to the methods
|
|
90
108
|
# `McpToolkit::Auth::Authority#introspection_payload` reads (see that module for
|
|
@@ -108,6 +126,39 @@ class McpToolkit::Configuration
|
|
|
108
126
|
# @return [Integer] session sliding-TTL in seconds.
|
|
109
127
|
attr_accessor :session_ttl
|
|
110
128
|
|
|
129
|
+
# --- rate limiting ---------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
# The built-in per-principal request cap enforced by the authority transport
|
|
132
|
+
# (McpToolkit::Authority::ControllerMethods#mcp_rate_limit!), counted against
|
|
133
|
+
# `cache_store` via McpToolkit::RateLimiter. nil (the default) DISABLES rate
|
|
134
|
+
# limiting entirely, so a pure host is unaffected until it opts in. Set an
|
|
135
|
+
# Integer to cap each principal to that many requests per `rate_limit_window`.
|
|
136
|
+
# The default `mcp_rate_limit!` reads this through the overridable
|
|
137
|
+
# `mcp_rate_limit_max_requests` hook, so a host that keeps the cap in its own
|
|
138
|
+
# constant/model overrides that hook rather than this value.
|
|
139
|
+
#
|
|
140
|
+
# @return [Integer, nil]
|
|
141
|
+
attr_accessor :rate_limit_max_requests
|
|
142
|
+
|
|
143
|
+
# The fixed rate-limit window, in seconds (default 3600 = 1 hour). Ignored
|
|
144
|
+
# while `rate_limit_max_requests` is nil.
|
|
145
|
+
#
|
|
146
|
+
# @return [Integer]
|
|
147
|
+
attr_accessor :rate_limit_window
|
|
148
|
+
|
|
149
|
+
# --- superuser (optional, first-class) -------------------------------------
|
|
150
|
+
|
|
151
|
+
# Optional resolver deciding whether a principal is a SUPERUSER — a cross-tenant
|
|
152
|
+
# caller that may reach `superusers_only!` resources. `->(principal) -> Boolean`.
|
|
153
|
+
# When set, McpToolkit::Authority::Context#superuser? calls it; when nil (the
|
|
154
|
+
# default) the context falls back to duck-typing `principal.superuser?` (false
|
|
155
|
+
# when the principal doesn't respond to it). Superuser is FULLY OPTIONAL: a host
|
|
156
|
+
# with no such concept leaves this nil and flags no `superusers_only!` resource,
|
|
157
|
+
# so no caller is ever a superuser.
|
|
158
|
+
#
|
|
159
|
+
# @return [#call, nil]
|
|
160
|
+
attr_accessor :superuser_resolver
|
|
161
|
+
|
|
111
162
|
# --- filtering -------------------------------------------------------------
|
|
112
163
|
|
|
113
164
|
# Escapes LIKE wildcards in `matches` / `does_not_match` filter values so they
|
|
@@ -124,6 +175,14 @@ class McpToolkit::Configuration
|
|
|
124
175
|
# nil lets the gem negotiate (recommended). Set only to force an older spec.
|
|
125
176
|
attr_accessor :protocol_version
|
|
126
177
|
|
|
178
|
+
# The protocol versions the hand-rolled AUTHORITY dispatcher
|
|
179
|
+
# (McpToolkit::Dispatcher) negotiates, newest first. `initialize` echoes the
|
|
180
|
+
# requested version when it is in this set, else the first (latest). Defaults to
|
|
181
|
+
# McpToolkit::Protocol::SUPPORTED_VERSIONS; override to pin a host's own set.
|
|
182
|
+
#
|
|
183
|
+
# @return [Array<String>]
|
|
184
|
+
attr_accessor :supported_protocol_versions
|
|
185
|
+
|
|
127
186
|
# The parent class (as a String, resolved via `constantize`) of the
|
|
128
187
|
# gem-provided McpToolkit::ServerController that McpToolkit::Engine mounts.
|
|
129
188
|
# Doorkeeper-style indirection so a satellite mounting the engine can keep
|
|
@@ -151,12 +210,111 @@ class McpToolkit::Configuration
|
|
|
151
210
|
# @return [McpToolkit::Registry]
|
|
152
211
|
attr_accessor :registry
|
|
153
212
|
|
|
213
|
+
# --- gateway / upstreams ---------------------------------------------------
|
|
214
|
+
|
|
215
|
+
# @return [Integer] HTTP open/read timeout (s) for a gateway's calls to an
|
|
216
|
+
# upstream MCP server (McpToolkit::Gateway::Client).
|
|
217
|
+
attr_accessor :upstream_timeout
|
|
218
|
+
# @return [Integer] TTL (s) for an upstream's cached, namespaced tool list in
|
|
219
|
+
# McpToolkit::Gateway::Aggregator.
|
|
220
|
+
attr_accessor :upstream_list_ttl
|
|
221
|
+
|
|
222
|
+
# The registry of upstream MCP servers this gateway aggregates + proxies to.
|
|
223
|
+
# Each config carries its own (like `registry`), so it resets with a fresh
|
|
224
|
+
# config. Register via the `register_upstream` sugar below or directly on this
|
|
225
|
+
# instance. Empty unless the app registers upstreams, so a non-gateway app is
|
|
226
|
+
# unaffected.
|
|
227
|
+
#
|
|
228
|
+
# @return [McpToolkit::Gateway::UpstreamRegistry]
|
|
229
|
+
attr_reader :upstreams
|
|
230
|
+
|
|
231
|
+
# --- authority hooks -------------------------------------------------------
|
|
232
|
+
#
|
|
233
|
+
# Injection points for the AUTHORITY transport (McpToolkit::Authority::
|
|
234
|
+
# ControllerMethods) so a PURE host drives billing/tenancy from config without
|
|
235
|
+
# subclassing. A host whose logic touches its own models overrides the matching
|
|
236
|
+
# hook METHOD on its McpToolkit::Authority::ServerController subclass instead;
|
|
237
|
+
# then these stay nil. All default to nil (a no-op).
|
|
238
|
+
|
|
239
|
+
# OPTIONAL escape hatch that FULLY REPLACES the built-in limiter: a
|
|
240
|
+
# `->(controller:, principal:)` that renders + halts when over the limit (or
|
|
241
|
+
# sets rate-limit headers when under). When set, `mcp_rate_limit!` delegates to
|
|
242
|
+
# it and the built-in (`rate_limit_max_requests`) is skipped. nil (the default)
|
|
243
|
+
# means the built-in runs instead. Most hosts want the built-in; reach for this
|
|
244
|
+
# only when the counting itself must live in app code.
|
|
245
|
+
#
|
|
246
|
+
# @return [#call, nil]
|
|
247
|
+
attr_accessor :rate_limiter
|
|
248
|
+
|
|
249
|
+
# Records ONE usage event for a single JSON-RPC call (called per batch element).
|
|
250
|
+
# `->(request_data:, account:, principal:, controller:)`. MUST never affect the
|
|
251
|
+
# MCP response. nil = no metering.
|
|
252
|
+
#
|
|
253
|
+
# @return [#call, nil]
|
|
254
|
+
attr_accessor :usage_recorder
|
|
255
|
+
|
|
256
|
+
# Persists accumulated usage after the response (an after_action).
|
|
257
|
+
# `->(controller:)`. MUST never affect the MCP response. nil = no flush.
|
|
258
|
+
#
|
|
259
|
+
# @return [#call, nil]
|
|
260
|
+
attr_accessor :usage_flusher
|
|
261
|
+
|
|
262
|
+
# Builds the opaque payload bound to a session on `initialize`. `->(principal:)`
|
|
263
|
+
# returning a Hash (or nil for none). Lets a host bind e.g.
|
|
264
|
+
# `{ token_id: principal.id }` so a revoked token can kill an in-flight session,
|
|
265
|
+
# WITHOUT overriding the controller's `mcp_session_data`. nil (the default) =>
|
|
266
|
+
# an empty session payload.
|
|
267
|
+
#
|
|
268
|
+
# @return [#call, nil]
|
|
269
|
+
attr_accessor :session_data_builder
|
|
270
|
+
|
|
271
|
+
# The host's tool catalog — the api-agnostic seam. Duck-typed; the dispatcher
|
|
272
|
+
# calls:
|
|
273
|
+
#
|
|
274
|
+
# provider.tool_definitions(context) -> [{ name:, description:, inputSchema: }]
|
|
275
|
+
# provider.find(name) -> a tool object, or nil
|
|
276
|
+
#
|
|
277
|
+
# where a tool object responds to `#required_permissions_scope` (String|nil, the
|
|
278
|
+
# gem's scope gate) and `#call(context:, **arguments)` (returns Hash|String,
|
|
279
|
+
# which the gem wraps into `{ content: [{ type: "text", text: }] }`). See
|
|
280
|
+
# McpToolkit::Tools::AuthorityBase for a base that satisfies this. nil = the host
|
|
281
|
+
# contributes no own tools (a pure gateway).
|
|
282
|
+
#
|
|
283
|
+
# @return [#tool_definitions, #find, nil]
|
|
284
|
+
attr_accessor :tool_provider
|
|
285
|
+
|
|
286
|
+
# --- generic tool naming ---------------------------------------------------
|
|
287
|
+
|
|
288
|
+
# A prefix prepended to the four GENERIC, Registry-backed authority tool names
|
|
289
|
+
# (`resources`, `resource_schema`, `get`, `list`) served by
|
|
290
|
+
# McpToolkit::Authority::RegistryToolProvider. Lets a host NAMESPACE its generic
|
|
291
|
+
# tools — e.g. set `"foo_"` and they advertise (and resolve) as `foo_resources`,
|
|
292
|
+
# `foo_resource_schema`, `foo_get`, `foo_list` — so distinct MCP surfaces don't
|
|
293
|
+
# collide and existing clients keep a stable, host-chosen name. Empty by default,
|
|
294
|
+
# so the tools keep their bare base names. The prefix value is the host's; the gem
|
|
295
|
+
# names no app concept.
|
|
296
|
+
#
|
|
297
|
+
# @return [String]
|
|
298
|
+
attr_accessor :generic_tool_name_prefix
|
|
299
|
+
|
|
300
|
+
# --- diagnostics -----------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
# Optional logger for gateway/session diagnostics. All call sites guard with
|
|
303
|
+
# `logger&.warn` / `logger&.error`, so nil (the default) silences them. A Rails
|
|
304
|
+
# host typically sets this to `Rails.logger`.
|
|
305
|
+
#
|
|
306
|
+
# @return [#warn, #error, nil]
|
|
307
|
+
attr_accessor :logger
|
|
308
|
+
|
|
154
309
|
# Vendor-neutral defaults; apps override the auth wiring + identity as needed.
|
|
155
310
|
def initialize
|
|
156
311
|
@server_name = "mcp-server"
|
|
157
312
|
@server_version = "1.0.0"
|
|
158
313
|
@server_instructions = nil
|
|
159
314
|
|
|
315
|
+
@gateway_client_name = nil
|
|
316
|
+
@gateway_client_version = nil
|
|
317
|
+
|
|
160
318
|
@serializer_base = nil # set lazily in #serializer_base to avoid load-order issues
|
|
161
319
|
|
|
162
320
|
@auth_role = :satellite
|
|
@@ -174,11 +332,56 @@ class McpToolkit::Configuration
|
|
|
174
332
|
@sql_sanitizer = McpToolkit::SqlSanitizer.new
|
|
175
333
|
|
|
176
334
|
@protocol_version = nil
|
|
335
|
+
@supported_protocol_versions = McpToolkit::Protocol::SUPPORTED_VERSIONS
|
|
177
336
|
@parent_controller = "ActionController::Base"
|
|
178
337
|
@account_meta_key = "mcp-toolkit/account-id"
|
|
179
338
|
@account_id_header = "X-MCP-Account-ID"
|
|
180
339
|
|
|
340
|
+
initialize_authority_hook_defaults
|
|
341
|
+
@generic_tool_name_prefix = ""
|
|
342
|
+
|
|
343
|
+
@upstream_timeout = 10
|
|
344
|
+
@upstream_list_ttl = 900 # 15 minutes
|
|
345
|
+
@logger = nil
|
|
346
|
+
|
|
181
347
|
@registry = McpToolkit::Registry.new
|
|
348
|
+
@upstreams = McpToolkit::Gateway::UpstreamRegistry.new
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# The authority transport's injection points all default to nil (a no-op): a
|
|
352
|
+
# pure satellite/gateway never touches them. `rate_limit_window` is the sole
|
|
353
|
+
# non-nil default (the window size only matters once a cap opts in).
|
|
354
|
+
def initialize_authority_hook_defaults
|
|
355
|
+
@rate_limiter = nil
|
|
356
|
+
@usage_recorder = nil
|
|
357
|
+
@usage_flusher = nil
|
|
358
|
+
@session_data_builder = nil
|
|
359
|
+
@tool_provider = nil
|
|
360
|
+
@rate_limit_max_requests = nil # nil = rate limiting disabled
|
|
361
|
+
@rate_limit_window = 3600 # 1 hour
|
|
362
|
+
@superuser_resolver = nil # nil = duck-type principal.superuser?
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Config sugar: register a gateway upstream. Delegates to `upstreams.register`,
|
|
366
|
+
# so a blank url is ignored (an unconfigured upstream is simply absent). Pass
|
|
367
|
+
# `public_tool_list: false` for an upstream whose tool list varies by caller
|
|
368
|
+
# privilege, to opt it out of the shared list cache.
|
|
369
|
+
#
|
|
370
|
+
# c.register_upstream(key: "notifications", url: ENV["NOTIFICATIONS_SERVER_URL"])
|
|
371
|
+
def register_upstream(key:, url:, public_tool_list: true)
|
|
372
|
+
upstreams.register(key:, url:, public_tool_list:)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# The gateway handshake client name, defaulting to the server identity when the
|
|
376
|
+
# host hasn't split it. Read (not stored) so a `server_name` change before the
|
|
377
|
+
# split is set still flows through.
|
|
378
|
+
def gateway_client_name
|
|
379
|
+
@gateway_client_name || server_name
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# The gateway handshake client version, defaulting to the server version.
|
|
383
|
+
def gateway_client_version
|
|
384
|
+
@gateway_client_version || server_version
|
|
182
385
|
end
|
|
183
386
|
|
|
184
387
|
# The serializer base, lazily defaulting to the gem's bundled DSL base. Lazy so
|