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
|
@@ -0,0 +1,150 @@
|
|
|
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. `name_prefix` is
|
|
47
|
+
# the host's `config.generic_tool_name_prefix`: it prefixes the advertised
|
|
48
|
+
# name AND is threaded through every sibling-tool reference in the
|
|
49
|
+
# description / input schema (see McpToolkit::ToolReferenceRewriter).
|
|
50
|
+
# `config` lets a tool adapt its prose to host configuration (see
|
|
51
|
+
# .description_text) — served docs must describe the behavior the host
|
|
52
|
+
# actually configured.
|
|
53
|
+
def definition(name_prefix: "", config: nil)
|
|
54
|
+
{
|
|
55
|
+
name: "#{name_prefix}#{tool_name}",
|
|
56
|
+
description: McpToolkit::ToolReferenceRewriter.rewrite(description_text(config), name_prefix),
|
|
57
|
+
inputSchema: McpToolkit::ToolReferenceRewriter.rewrite(input_schema, name_prefix)
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Hook for a tool whose description depends on host configuration
|
|
62
|
+
# (Tools::List swaps its bare-value grammar per
|
|
63
|
+
# config.bare_filter_value_semantics). Default: the static description.
|
|
64
|
+
def description_text(_config)
|
|
65
|
+
_description
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
attr_reader :config
|
|
70
|
+
|
|
71
|
+
def initialize(config:)
|
|
72
|
+
@config = config
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The dispatcher's central scope gate reads this; nil = no STATIC scope. The
|
|
76
|
+
# real, per-resource scope is enforced dynamically in #ensure_scope! (the scope
|
|
77
|
+
# depends on the `resource` argument, unknown until #call).
|
|
78
|
+
def required_permissions_scope
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def registry
|
|
85
|
+
config.registry
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Resolves the `resource` argument to a registered descriptor, raising the
|
|
89
|
+
# protocol InvalidParams (=> JSON-RPC -32602) for a blank or unknown name so the
|
|
90
|
+
# dispatcher renders a clean top-level error.
|
|
91
|
+
def resolve_descriptor(name)
|
|
92
|
+
raise McpToolkit::Protocol::InvalidParams, "resource is required" if name.to_s.strip.empty?
|
|
93
|
+
|
|
94
|
+
registry.fetch(name)
|
|
95
|
+
rescue McpToolkit::Registry::UnknownResource => e
|
|
96
|
+
raise McpToolkit::Protocol::InvalidParams, e.message
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Refuses a superuser-only resource for a non-superuser caller (get / list /
|
|
100
|
+
# resource_schema). `resources` HIDES such resources instead — see that tool.
|
|
101
|
+
def ensure_resource_accessible!(descriptor, context)
|
|
102
|
+
return unless descriptor.superusers_only?
|
|
103
|
+
return if context.superuser?
|
|
104
|
+
|
|
105
|
+
raise McpToolkit::Protocol::InvalidRequest,
|
|
106
|
+
"#{descriptor.name} is restricted to superuser (user-scoped) MCP tokens"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Enforces the resource's effective required scope against the principal. Blank
|
|
110
|
+
# scope => no check. Mirrors the dispatcher's central gate error shape
|
|
111
|
+
# (InvalidRequest), keeping scope refusals byte-consistent across host tools.
|
|
112
|
+
def ensure_scope!(descriptor, context)
|
|
113
|
+
required = registry.required_scope_for(descriptor)
|
|
114
|
+
return if required.to_s.empty?
|
|
115
|
+
return if context.principal&.authorized_for_scope?(required)
|
|
116
|
+
|
|
117
|
+
raise McpToolkit::Protocol::InvalidRequest, "This token lacks the #{required.inspect} scope"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# get / list read tenant data, so they REQUIRE a resolved account: a superuser
|
|
121
|
+
# token that selected none would otherwise reach `scope.call(nil)` and leak
|
|
122
|
+
# across tenants. resource_schema / resources (shape only) do not call this.
|
|
123
|
+
def ensure_account!(context)
|
|
124
|
+
return if context.account
|
|
125
|
+
|
|
126
|
+
raise McpToolkit::Protocol::InvalidParams,
|
|
127
|
+
"an account must be selected (pass account_id) to read this resource"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Runs an executor, translating a data-layer McpToolkit::Errors::InvalidParams
|
|
131
|
+
# (bad id, unknown filter/field key, ...) into the protocol InvalidParams the
|
|
132
|
+
# dispatcher renders as JSON-RPC -32602 (rather than letting it fall through to
|
|
133
|
+
# the dispatcher's generic -32603 internal-error mapping).
|
|
134
|
+
def run_executor
|
|
135
|
+
yield
|
|
136
|
+
rescue McpToolkit::Errors::InvalidParams => e
|
|
137
|
+
raise McpToolkit::Protocol::InvalidParams, e.message
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Rejects tool arguments outside the declared input schema (-32602) instead of
|
|
141
|
+
# silently ignoring them — a typo'd argument name should surface, not no-op.
|
|
142
|
+
# `list` deliberately does NOT use this: its extra top-level arguments are the
|
|
143
|
+
# resource-specific custom filters. (`account_id` is consumed by the
|
|
144
|
+
# transport's account resolution, so every tool tolerates it explicitly.)
|
|
145
|
+
def reject_unknown_arguments!(extra)
|
|
146
|
+
return if extra.empty?
|
|
147
|
+
|
|
148
|
+
raise McpToolkit::Protocol::InvalidParams, "unknown argument(s): #{extra.keys.join(", ")}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
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, **extra)
|
|
49
|
+
reject_unknown_arguments!(extra.except(:account_id))
|
|
50
|
+
descriptor = resolve_descriptor(resource)
|
|
51
|
+
ensure_resource_accessible!(descriptor, context)
|
|
52
|
+
ensure_scope!(descriptor, context)
|
|
53
|
+
ensure_account!(context)
|
|
54
|
+
|
|
55
|
+
run_executor do
|
|
56
|
+
McpToolkit::GetExecutor.call(resource: descriptor, scope_root: context.account, id:, fields:)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
# The bare-value grammar bullet, per config.bare_filter_value_semantics: the
|
|
11
|
+
# served description must state the semantics the host ACTUALLY configured —
|
|
12
|
+
# advertising comma/"null" tokenization to clients of a :literal host would
|
|
13
|
+
# send them filters that silently match nothing. The :tokenized text is the
|
|
14
|
+
# exact bullet embedded in the static description below (spec-pinned), so
|
|
15
|
+
# .description_text can swap it by plain substring substitution; both use
|
|
16
|
+
# `<<-` (no dedent) to carry the description's rendered 2-space indentation.
|
|
17
|
+
BARE_VALUE_GRAMMAR = {
|
|
18
|
+
tokenized: <<-TEXT.rstrip,
|
|
19
|
+
- A bare value matches by equality. A comma-separated string or an array of scalars
|
|
20
|
+
matches ANY of the values (IN), e.g. { "status": "booked,canceled" } or
|
|
21
|
+
{ "status": ["booked", "canceled"] }. The string "null" (or a JSON null) matches
|
|
22
|
+
records where the value is NULL.
|
|
23
|
+
TEXT
|
|
24
|
+
literal: <<-TEXT.rstrip
|
|
25
|
+
- A bare value matches by equality, LITERALLY: a comma-separated string is a single
|
|
26
|
+
value and the string "null" is the literal string. A JSON null matches records
|
|
27
|
+
where the value is NULL. An array of scalars matches ANY of its values (IN).
|
|
28
|
+
TEXT
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# Swaps the bare-value bullet for the host's configured semantics; the rest
|
|
32
|
+
# of the description is mode-independent.
|
|
33
|
+
def self.description_text(config)
|
|
34
|
+
return _description unless config && config.bare_filter_value_semantics == :literal
|
|
35
|
+
|
|
36
|
+
_description.sub(BARE_VALUE_GRAMMAR[:tokenized], BARE_VALUE_GRAMMAR[:literal])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
tool_name "list"
|
|
40
|
+
description <<~DESC.strip
|
|
41
|
+
Fetch a paginated list of records from a read-only resource. Pass the resource name as
|
|
42
|
+
`resource`. Use the `resources` tool to discover resources and `resource_schema` to learn a
|
|
43
|
+
resource's shape.
|
|
44
|
+
|
|
45
|
+
Standard filters:
|
|
46
|
+
- ids: comma-separated list of IDs to fetch
|
|
47
|
+
- updated_since: ISO 8601 timestamp; only records updated after this time
|
|
48
|
+
- limit: page size (default 25, max 100)
|
|
49
|
+
- offset: pagination offset (default 0)
|
|
50
|
+
|
|
51
|
+
Per-attribute filters:
|
|
52
|
+
- filter: an object of { <key>: <value> } filters, applied ON TOP of the account scope
|
|
53
|
+
(they can only narrow, never widen). Each resource advertises its available filter keys
|
|
54
|
+
and operators via `resource_schema`. Unknown keys are rejected.
|
|
55
|
+
- A bare value matches by equality. A comma-separated string or an array of scalars
|
|
56
|
+
matches ANY of the values (IN), e.g. { "status": "booked,canceled" } or
|
|
57
|
+
{ "status": ["booked", "canceled"] }. The string "null" (or a JSON null) matches
|
|
58
|
+
records where the value is NULL.
|
|
59
|
+
- An operator condition is an object { "op": <operator>, "value": <value> }, e.g.
|
|
60
|
+
{ "price": { "op": "gteq", "value": 100 } }. An array of conditions ANDs them into a
|
|
61
|
+
range: { "price": [{ "op": "gteq", "value": 100 }, { "op": "lt", "value": 200 }] }.
|
|
62
|
+
Each attribute's supported operators are listed in `resource_schema`.
|
|
63
|
+
- Some filter keys require a companion key (e.g. a polymorphic id and its type) —
|
|
64
|
+
`resource_schema` advertises these under a relationship's `filter.requires`; pass
|
|
65
|
+
both keys together.
|
|
66
|
+
|
|
67
|
+
Resource-specific filters:
|
|
68
|
+
- Some resources accept additional filters advertised in `resource_schema` under
|
|
69
|
+
`resource_filters`. Pass each as a TOP-LEVEL argument (NOT inside `filter`), e.g.
|
|
70
|
+
{ "resource": "...", "<name>": <value> }.
|
|
71
|
+
|
|
72
|
+
Sparse fieldset:
|
|
73
|
+
- fields: names of the attributes and/or relationships to include in each record, as an
|
|
74
|
+
array or a comma-separated string. Omit to return every field. Include "id" if you need
|
|
75
|
+
it. Valid names come from a resource's `resource_schema`; unknown names are rejected.
|
|
76
|
+
|
|
77
|
+
For tokens that span multiple accounts (superuser), pass `account_id` to pin the active
|
|
78
|
+
account; account-scoped tokens may omit it. The response shape is
|
|
79
|
+
{ "<resource>": [...], "meta": { total_count, limit, offset } }.
|
|
80
|
+
DESC
|
|
81
|
+
|
|
82
|
+
input_schema(
|
|
83
|
+
{
|
|
84
|
+
type: "object",
|
|
85
|
+
properties: {
|
|
86
|
+
resource: {
|
|
87
|
+
type: "string",
|
|
88
|
+
description: "Resource name (use the `resources` tool to discover valid values)"
|
|
89
|
+
},
|
|
90
|
+
account_id: {
|
|
91
|
+
type: "integer",
|
|
92
|
+
description: "Account to operate on. Required for superuser tokens; ignored otherwise."
|
|
93
|
+
},
|
|
94
|
+
ids: { type: "string", description: "Comma-separated list of IDs to fetch" },
|
|
95
|
+
updated_since: {
|
|
96
|
+
type: "string",
|
|
97
|
+
description: "ISO 8601 timestamp; only records updated after this time"
|
|
98
|
+
},
|
|
99
|
+
filter: {
|
|
100
|
+
type: "object",
|
|
101
|
+
description: "Per-attribute filters, e.g. { \"booking_id\": 42 }. See a resource's " \
|
|
102
|
+
"`resource_schema` `filters` for the keys and operators it accepts.",
|
|
103
|
+
additionalProperties: true
|
|
104
|
+
},
|
|
105
|
+
limit: { type: "integer", description: "Page size (default 25, max 100)" },
|
|
106
|
+
offset: { type: "integer", description: "Pagination offset (default 0)" },
|
|
107
|
+
fields: {
|
|
108
|
+
type: %w[array string],
|
|
109
|
+
items: { type: "string" },
|
|
110
|
+
description: "Sparse fieldset — names of attributes and/or relationships to include in " \
|
|
111
|
+
"each record, as an array or a comma-separated string. Omit to return every " \
|
|
112
|
+
"field. Include \"id\" if you need it. Unknown names are rejected."
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
required: ["resource"],
|
|
116
|
+
# Resource-specific filters (resource_schema's `resource_filters`) arrive as
|
|
117
|
+
# top-level arguments, so the schema must not advertise a closed shape.
|
|
118
|
+
additionalProperties: true
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def call(context:, resource: nil, **params)
|
|
123
|
+
descriptor = resolve_descriptor(resource)
|
|
124
|
+
ensure_resource_accessible!(descriptor, context)
|
|
125
|
+
ensure_scope!(descriptor, context)
|
|
126
|
+
ensure_account!(context)
|
|
127
|
+
|
|
128
|
+
run_executor do
|
|
129
|
+
McpToolkit::ListExecutor.call(resource: descriptor, scope_root: context.account, params:)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
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/operator filter keys the `list` tool accepts in
|
|
20
|
+
its `filter` argument
|
|
21
|
+
- resource_filters: resource-specific filters, if any — each is passed as a TOP-LEVEL
|
|
22
|
+
argument of the `list` tool (NOT inside `filter`), e.g. { "resource": "...",
|
|
23
|
+
"<name>": <value> }
|
|
24
|
+
- filter_examples: ready-to-use `filter` payloads for this resource
|
|
25
|
+
A relationship's `filter` block lists the keys that filter by it; when it names a
|
|
26
|
+
`requires` key (e.g. a polymorphic id needing its type), pass BOTH keys together.
|
|
27
|
+
The `attributes` and `relationships` names are also the valid values for the `fields` sparse
|
|
28
|
+
fieldset argument on `get` / `list`. Call this before `list` to learn a resource's shape.
|
|
29
|
+
DESC
|
|
30
|
+
|
|
31
|
+
input_schema(
|
|
32
|
+
{
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
resource: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description: "Resource name (use the `resources` tool to discover valid values)"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
required: ["resource"]
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def call(context:, resource: nil, **extra)
|
|
45
|
+
reject_unknown_arguments!(extra.except(:account_id))
|
|
46
|
+
descriptor = resolve_descriptor(resource)
|
|
47
|
+
ensure_resource_accessible!(descriptor, context)
|
|
48
|
+
ensure_scope!(descriptor, context)
|
|
49
|
+
|
|
50
|
+
McpToolkit::ResourceSchema.call(descriptor, registry:)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
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, a short description, whether it accepts filters (`filterable`) and — when
|
|
15
|
+
present — a usage `note` (read it before interpreting the resource's data). Call this once
|
|
16
|
+
at the start of a session to learn what exists, then use `resource_schema` for a specific
|
|
17
|
+
resource's attributes, relationships and filters.
|
|
18
|
+
DESC
|
|
19
|
+
|
|
20
|
+
# Unlike get / resource_schema (strict kwargs pre-gem), the pre-gem resources
|
|
21
|
+
# tool tolerated ANY extra argument — so this one stays tolerant.
|
|
22
|
+
def call(context:, **_args)
|
|
23
|
+
{
|
|
24
|
+
resources: visible_resources(context).map do |resource|
|
|
25
|
+
{
|
|
26
|
+
name: resource.name,
|
|
27
|
+
description: resource.description,
|
|
28
|
+
filterable: filterable?(resource),
|
|
29
|
+
note: resource.note
|
|
30
|
+
}.compact
|
|
31
|
+
end
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Superuser-only resources are hidden from a non-superuser caller.
|
|
38
|
+
def visible_resources(context)
|
|
39
|
+
registry.resources.reject { |resource| resource.superusers_only? && !context.superuser? }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether the resource can be filtered at all — via the generic allowlist OR a
|
|
43
|
+
# resource-specific custom filter. Reading `filterable_columns` resolves a
|
|
44
|
+
# lazily-declared allowlist, which may run host code (e.g. a DB-backed column
|
|
45
|
+
# list). One resource's failing resolution must not take down the whole
|
|
46
|
+
# discovery index — this is the tool every session calls first — so a raise
|
|
47
|
+
# degrades to nil (the `filterable` key is omitted for that resource) and the
|
|
48
|
+
# unresolved source is retried on the next read (see Resource#filterable).
|
|
49
|
+
def filterable?(resource)
|
|
50
|
+
resource.filterable_columns.any? || resource.custom_filters.any?
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
config.logger&.warn("mcp_toolkit: filterable resolution failed for #{resource.name}: #{e.message}")
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
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
|