mcp_toolkit 0.4.0 → 0.6.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 +418 -0
- data/README.md +174 -0
- data/app/views/mcp_toolkit/oauth/authorize.html.erb +83 -0
- data/config/routes.rb +22 -1
- data/lib/mcp_toolkit/authority/controller_methods.rb +39 -0
- data/lib/mcp_toolkit/authority/registry_tool_provider.rb +9 -10
- data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
- data/lib/mcp_toolkit/authority/tools/base.rb +31 -3
- data/lib/mcp_toolkit/authority/tools/get.rb +2 -1
- data/lib/mcp_toolkit/authority/tools/list.rb +49 -1
- data/lib/mcp_toolkit/authority/tools/resource_schema.rb +10 -2
- data/lib/mcp_toolkit/authority/tools/resources.rb +26 -4
- data/lib/mcp_toolkit/configuration.rb +526 -7
- data/lib/mcp_toolkit/dispatcher.rb +17 -3
- data/lib/mcp_toolkit/engine.rb +20 -2
- data/lib/mcp_toolkit/engine_controllers.rb +80 -5
- data/lib/mcp_toolkit/filtering.rb +168 -23
- data/lib/mcp_toolkit/gateway/aggregator.rb +25 -5
- data/lib/mcp_toolkit/list_executor.rb +48 -4
- data/lib/mcp_toolkit/oauth/controller_methods.rb +426 -0
- data/lib/mcp_toolkit/oauth.rb +23 -0
- data/lib/mcp_toolkit/resource.rb +55 -6
- data/lib/mcp_toolkit/resource_schema.rb +125 -16
- data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
- data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
- data/lib/mcp_toolkit/session.rb +2 -2
- data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
- data/lib/mcp_toolkit/tools/authority_base.rb +23 -2
- 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/usage_metering/recorder.rb +28 -2
- data/lib/mcp_toolkit/version.rb +1 -1
- data/lib/mcp_toolkit.rb +8 -2
- metadata +8 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
The bridge's only human-facing surface: paste an access token you already hold.
|
|
3
|
+
|
|
4
|
+
Rendered with `layout: false`, so this is a whole document and carries its own
|
|
5
|
+
styles inline — it must not depend on the host's asset pipeline. A host that
|
|
6
|
+
wants its own branding defines app/views/mcp_toolkit/oauth/authorize.html.erb
|
|
7
|
+
in its own tree, which takes precedence over this one.
|
|
8
|
+
|
|
9
|
+
The authorization parameters ride through as hidden fields because the bridge
|
|
10
|
+
keeps no state between the two legs. They are re-validated on POST — the
|
|
11
|
+
redirect_uri against the allowlist — so a tampered field cannot widen anything.
|
|
12
|
+
%>
|
|
13
|
+
<!DOCTYPE html>
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="utf-8">
|
|
17
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
18
|
+
<meta name="robots" content="noindex, nofollow">
|
|
19
|
+
<title>Connect to <%= McpToolkit.config.server_name %></title>
|
|
20
|
+
<style>
|
|
21
|
+
:root { color-scheme: light dark; }
|
|
22
|
+
body {
|
|
23
|
+
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
24
|
+
padding: 1.5rem; box-sizing: border-box;
|
|
25
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif; line-height: 1.5;
|
|
26
|
+
background: #f4f4f5; color: #18181b;
|
|
27
|
+
}
|
|
28
|
+
main { width: 100%; max-width: 26rem; background: #fff; border-radius: 0.75rem; padding: 2rem;
|
|
29
|
+
box-shadow: 0 1px 3px rgb(0 0 0 / 0.1), 0 8px 24px rgb(0 0 0 / 0.06); }
|
|
30
|
+
h1 { margin: 0 0 0.5rem; font-size: 1.25rem; }
|
|
31
|
+
p { margin: 0 0 1.5rem; color: #52525b; font-size: 0.9375rem; }
|
|
32
|
+
label { display: block; margin-bottom: 0.375rem; font-size: 0.875rem; font-weight: 600; }
|
|
33
|
+
input[type="password"] {
|
|
34
|
+
width: 100%; box-sizing: border-box; padding: 0.625rem 0.75rem; font-size: 1rem;
|
|
35
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
36
|
+
border: 1px solid #d4d4d8; border-radius: 0.375rem; background: #fff; color: inherit;
|
|
37
|
+
}
|
|
38
|
+
input[type="password"]:focus { outline: 2px solid #2563eb; outline-offset: 1px; border-color: #2563eb; }
|
|
39
|
+
button {
|
|
40
|
+
width: 100%; margin-top: 1.25rem; padding: 0.625rem 1rem; font-size: 0.9375rem; font-weight: 600;
|
|
41
|
+
color: #fff; background: #18181b; border: 0; border-radius: 0.375rem; cursor: pointer;
|
|
42
|
+
}
|
|
43
|
+
button:hover { background: #3f3f46; }
|
|
44
|
+
.error {
|
|
45
|
+
margin: 0 0 1.25rem; padding: 0.75rem; border-radius: 0.375rem; font-size: 0.875rem;
|
|
46
|
+
background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;
|
|
47
|
+
}
|
|
48
|
+
@media (prefers-color-scheme: dark) {
|
|
49
|
+
body { background: #18181b; color: #fafafa; }
|
|
50
|
+
main { background: #27272a; box-shadow: none; border: 1px solid #3f3f46; }
|
|
51
|
+
p { color: #a1a1aa; }
|
|
52
|
+
input[type="password"] { background: #18181b; border-color: #52525b; }
|
|
53
|
+
button { background: #fafafa; color: #18181b; }
|
|
54
|
+
button:hover { background: #d4d4d8; }
|
|
55
|
+
.error { background: #450a0a; border-color: #7f1d1d; color: #fecaca; }
|
|
56
|
+
}
|
|
57
|
+
</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<main>
|
|
61
|
+
<h1>Connect to <%= McpToolkit.config.server_name %></h1>
|
|
62
|
+
<p>Paste your access token to finish connecting. It is not shown again after this step.</p>
|
|
63
|
+
|
|
64
|
+
<% if @mcp_oauth_error.present? %>
|
|
65
|
+
<div class="error" role="alert"><%= @mcp_oauth_error %></div>
|
|
66
|
+
<% end %>
|
|
67
|
+
|
|
68
|
+
<%= form_tag request.path, method: :post do %>
|
|
69
|
+
<%= hidden_field_tag :redirect_uri, params[:redirect_uri] %>
|
|
70
|
+
<%= hidden_field_tag :state, params[:state] %>
|
|
71
|
+
<%= hidden_field_tag :code_challenge, params[:code_challenge] %>
|
|
72
|
+
<%= hidden_field_tag :code_challenge_method, params[:code_challenge_method] %>
|
|
73
|
+
|
|
74
|
+
<label for="access_token">Access token</label>
|
|
75
|
+
<%= password_field_tag :access_token, nil, id: "access_token", autocomplete: "off",
|
|
76
|
+
autocapitalize: "off", autocorrect: "off", spellcheck: false,
|
|
77
|
+
autofocus: true, required: true %>
|
|
78
|
+
|
|
79
|
+
<button type="submit">Connect</button>
|
|
80
|
+
<% end %>
|
|
81
|
+
</main>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
data/config/routes.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Engine routes, drawn through Rails' routes_reloader so they survive route
|
|
4
4
|
# reloads (a class-body `routes.draw` is wiped when the app re-draws the engine's
|
|
5
|
-
# route set on boot/reload). Mounted by a satellite via:
|
|
5
|
+
# route set on boot/reload). Mounted by a satellite or an authority via:
|
|
6
6
|
#
|
|
7
7
|
# mount McpToolkit::Engine => "/mcp"
|
|
8
8
|
#
|
|
@@ -30,4 +30,25 @@ McpToolkit::Engine.routes.draw do
|
|
|
30
30
|
# is true whenever `auth_role == :authority`). The controller also fails safe
|
|
31
31
|
# (no `token_authenticator` => `{ valid: false }`), so this is defence in depth.
|
|
32
32
|
post "tokens/introspect", to: "tokens#introspect" if McpToolkit.config.authority?
|
|
33
|
+
|
|
34
|
+
# The OAuth authorization bridge (McpToolkit::Oauth::ControllerMethods). Drawn
|
|
35
|
+
# only when the bridge is configured — `oauth_bridge?` is authority-only AND
|
|
36
|
+
# requires a redirect-uri allowlist — so a satellite, or any host that has not
|
|
37
|
+
# opted in, gets no such routes at all. Same reasoning as the introspection
|
|
38
|
+
# route above: the routes file is evaluated through the routes_reloader, after
|
|
39
|
+
# the host's initializers/to_prepare, so the config is already set.
|
|
40
|
+
#
|
|
41
|
+
# The two metadata documents are NOT here: a client looks for them at the origin
|
|
42
|
+
# root, which an engine mounted under a path cannot draw. The host draws them
|
|
43
|
+
# with `McpToolkit.draw_oauth_metadata_routes(self)`.
|
|
44
|
+
# `format: false` on each, as on the metadata routes the host draws: without it
|
|
45
|
+
# Rails' optional `(.:format)` segment matches, so `/mcp/oauth/authorize.json`
|
|
46
|
+
# reaches the action, finds no JSON template, and 500s — an unauthenticated
|
|
47
|
+
# error on a public endpoint, for a format the bridge never speaks.
|
|
48
|
+
if McpToolkit.config.oauth_bridge?
|
|
49
|
+
get "oauth/authorize", to: "oauth#authorize", format: false
|
|
50
|
+
post "oauth/authorize", to: "oauth#approve", format: false
|
|
51
|
+
post "oauth/token", to: "oauth#token", format: false
|
|
52
|
+
post "oauth/register", to: "oauth#register", format: false
|
|
53
|
+
end
|
|
33
54
|
end
|
|
@@ -247,12 +247,27 @@ module McpToolkit::Authority::ControllerMethods
|
|
|
247
247
|
# ---- request handling -----------------------------------------------
|
|
248
248
|
|
|
249
249
|
def mcp_handle_batch(requests)
|
|
250
|
+
mcp_enforce_batch_limit!(requests)
|
|
250
251
|
responses = requests.filter_map { |req| mcp_process_single_request(req) }
|
|
251
252
|
return head :accepted if responses.empty?
|
|
252
253
|
|
|
253
254
|
mcp_render_response(responses)
|
|
254
255
|
end
|
|
255
256
|
|
|
257
|
+
# Rejects an oversized JSON-RPC batch BEFORE any element runs. Rate limiting is
|
|
258
|
+
# a per-HTTP-request before_action, so without this an authenticated caller
|
|
259
|
+
# could fan out unbounded work (N tool executions / N blocking upstream calls)
|
|
260
|
+
# under a single rate-limit tick. Raised as a boundary Protocol::Error →
|
|
261
|
+
# rendered as a JSON-RPC error envelope (null id) by the rescue_from hook.
|
|
262
|
+
# `config.max_batch_size = nil` disables the cap.
|
|
263
|
+
def mcp_enforce_batch_limit!(requests)
|
|
264
|
+
max = mcp_config.max_batch_size
|
|
265
|
+
return if max.nil? || requests.size <= max
|
|
266
|
+
|
|
267
|
+
raise McpToolkit::Protocol::InvalidRequest,
|
|
268
|
+
"JSON-RPC batch too large: #{requests.size} requests exceeds the maximum of #{max}"
|
|
269
|
+
end
|
|
270
|
+
|
|
256
271
|
def mcp_handle_single(request_data)
|
|
257
272
|
response_data = mcp_process_single_request(request_data)
|
|
258
273
|
return head :accepted if response_data.nil?
|
|
@@ -366,6 +381,7 @@ module McpToolkit::Authority::ControllerMethods
|
|
|
366
381
|
# ---- error renders --------------------------------------------------
|
|
367
382
|
|
|
368
383
|
def mcp_render_unauthorized(message)
|
|
384
|
+
mcp_set_authenticate_challenge
|
|
369
385
|
render json: {
|
|
370
386
|
jsonrpc: McpToolkit::Protocol::JSONRPC_VERSION,
|
|
371
387
|
id: nil,
|
|
@@ -376,6 +392,29 @@ module McpToolkit::Authority::ControllerMethods
|
|
|
376
392
|
}, status: :unauthorized
|
|
377
393
|
end
|
|
378
394
|
|
|
395
|
+
# Points an unauthenticated caller at the OAuth bridge's protected-resource
|
|
396
|
+
# metadata (RFC 9728). This header is what a hosted MCP client waits for before
|
|
397
|
+
# it will start an authorization flow at all — without it, a 401 is just a
|
|
398
|
+
# failure. It also makes the metadata's location OURS to state rather than the
|
|
399
|
+
# client's to guess: RFC 9728 has the client fetch this URL directly, so the
|
|
400
|
+
# path-scoped location is found without probing the origin's bare well-known
|
|
401
|
+
# path. Emitted only when the bridge is configured, so a host that has not opted
|
|
402
|
+
# in keeps its 401 byte-identical.
|
|
403
|
+
#
|
|
404
|
+
# `request.base_url` honours `X-Forwarded-Host`, so it is caller-influenced and
|
|
405
|
+
# cannot be interpolated into a quoted-string parameter unescaped: a host
|
|
406
|
+
# carrying a `"` would close the quotes and let a caller append auth-params of
|
|
407
|
+
# their own. A URL has no business containing one, so refuse rather than escape
|
|
408
|
+
# — an origin that odd is a misconfiguration to notice, not to render.
|
|
409
|
+
def mcp_set_authenticate_challenge
|
|
410
|
+
return unless mcp_config.oauth_bridge?
|
|
411
|
+
|
|
412
|
+
metadata_url = "#{request.base_url}#{mcp_config.oauth_protected_resource_path}"
|
|
413
|
+
return if metadata_url.include?('"')
|
|
414
|
+
|
|
415
|
+
response.headers["WWW-Authenticate"] = %(Bearer resource_metadata="#{metadata_url}")
|
|
416
|
+
end
|
|
417
|
+
|
|
379
418
|
def mcp_render_session_not_found
|
|
380
419
|
render json: {
|
|
381
420
|
jsonrpc: McpToolkit::Protocol::JSONRPC_VERSION,
|
|
@@ -20,11 +20,13 @@ class McpToolkit::Authority::RegistryToolProvider
|
|
|
20
20
|
# actually advertised in `tools/list` and matched in `tools/call` is this base
|
|
21
21
|
# name PREFIXED with `config.generic_tool_name_prefix` (empty by default, so the
|
|
22
22
|
# bare base name), letting a host namespace its generic tools.
|
|
23
|
+
# Alphabetical by base name — the order tools/list advertises them in
|
|
24
|
+
# (matches the pre-gem contract adopting hosts' clients observed).
|
|
23
25
|
TOOLS = {
|
|
24
|
-
"resources" => McpToolkit::Authority::Tools::Resources,
|
|
25
|
-
"resource_schema" => McpToolkit::Authority::Tools::ResourceSchema,
|
|
26
26
|
"get" => McpToolkit::Authority::Tools::Get,
|
|
27
|
-
"list" => McpToolkit::Authority::Tools::List
|
|
27
|
+
"list" => McpToolkit::Authority::Tools::List,
|
|
28
|
+
"resource_schema" => McpToolkit::Authority::Tools::ResourceSchema,
|
|
29
|
+
"resources" => McpToolkit::Authority::Tools::Resources
|
|
28
30
|
}.freeze
|
|
29
31
|
|
|
30
32
|
def initialize(config:)
|
|
@@ -32,9 +34,11 @@ class McpToolkit::Authority::RegistryToolProvider
|
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
# The four static generic tool definitions (context-independent), each advertised
|
|
35
|
-
# under its PREFIXED name so `tools/list` shows the host's namespaced names.
|
|
37
|
+
# under its PREFIXED name so `tools/list` shows the host's namespaced names. The
|
|
38
|
+
# prefix is threaded into each definition so sibling-tool references in the
|
|
39
|
+
# description / input schema name the prefixed tools too (see Tools::Base.definition).
|
|
36
40
|
def tool_definitions(_context)
|
|
37
|
-
TOOLS.map { |
|
|
41
|
+
TOOLS.map { |_base_name, klass| klass.definition(name_prefix: prefix, config: @config) }
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
# A tool instance bound to this provider's config, or nil for an unknown name.
|
|
@@ -54,11 +58,6 @@ class McpToolkit::Authority::RegistryToolProvider
|
|
|
54
58
|
@config.generic_tool_name_prefix.to_s
|
|
55
59
|
end
|
|
56
60
|
|
|
57
|
-
# The advertised name for a base tool: the configured prefix followed by the base.
|
|
58
|
-
def prefixed(base_name)
|
|
59
|
-
"#{prefix}#{base_name}"
|
|
60
|
-
end
|
|
61
|
-
|
|
62
61
|
# Recovers the base tool name from an advertised name by stripping the configured
|
|
63
62
|
# prefix, or nil when the name does not carry the (non-empty) prefix.
|
|
64
63
|
def base_name_for(name)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A `tool_provider` serving exactly one bespoke authority tool alongside the
|
|
4
|
+
# generic Registry-backed ones — the shape every host with a single custom tool
|
|
5
|
+
# would otherwise hand-roll. Used automatically by the composed default
|
|
6
|
+
# provider for bare tool classes in `config.extra_tool_providers`.
|
|
7
|
+
#
|
|
8
|
+
# The tool must expose `.definition` (the tools/list entry) and `.tool_name`,
|
|
9
|
+
# and itself satisfy the dispatcher's tool contract (`required_permissions_scope`
|
|
10
|
+
# + `call`) — e.g. a class built on McpToolkit::Tools::AuthorityBase. It is
|
|
11
|
+
# advertised unconditionally; its scope/authorization gates are enforced at
|
|
12
|
+
# call time (by the dispatcher and the tool itself).
|
|
13
|
+
class McpToolkit::Authority::SingleToolProvider
|
|
14
|
+
def initialize(tool)
|
|
15
|
+
@tool = tool
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def tool_definitions(_context)
|
|
19
|
+
[@tool.definition]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def find(name)
|
|
23
|
+
name.to_s == @tool.tool_name ? @tool : nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -43,9 +43,26 @@ class McpToolkit::Authority::Tools::Base
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
# The static tool definition returned by the provider's `tool_definitions`
|
|
46
|
-
# (part of `tools/list`). Generic and context-independent.
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
49
66
|
end
|
|
50
67
|
end
|
|
51
68
|
|
|
@@ -119,4 +136,15 @@ class McpToolkit::Authority::Tools::Base
|
|
|
119
136
|
rescue McpToolkit::Errors::InvalidParams => e
|
|
120
137
|
raise McpToolkit::Protocol::InvalidParams, e.message
|
|
121
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
|
|
122
150
|
end
|
|
@@ -45,7 +45,8 @@ class McpToolkit::Authority::Tools::Get < McpToolkit::Authority::Tools::Base
|
|
|
45
45
|
}
|
|
46
46
|
)
|
|
47
47
|
|
|
48
|
-
def call(context:, resource: nil, id: nil, fields: nil, **
|
|
48
|
+
def call(context:, resource: nil, id: nil, fields: nil, **extra)
|
|
49
|
+
reject_unknown_arguments!(extra.except(:account_id))
|
|
49
50
|
descriptor = resolve_descriptor(resource)
|
|
50
51
|
ensure_resource_accessible!(descriptor, context)
|
|
51
52
|
ensure_scope!(descriptor, context)
|
|
@@ -7,6 +7,35 @@
|
|
|
7
7
|
# custom filters, pagination, and sparse fieldsets are all handled by the reused
|
|
8
8
|
# McpToolkit::ListExecutor.
|
|
9
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
|
+
|
|
10
39
|
tool_name "list"
|
|
11
40
|
description <<~DESC.strip
|
|
12
41
|
Fetch a paginated list of records from a read-only resource. Pass the resource name as
|
|
@@ -23,6 +52,22 @@ class McpToolkit::Authority::Tools::List < McpToolkit::Authority::Tools::Base
|
|
|
23
52
|
- filter: an object of { <key>: <value> } filters, applied ON TOP of the account scope
|
|
24
53
|
(they can only narrow, never widen). Each resource advertises its available filter keys
|
|
25
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> }.
|
|
26
71
|
|
|
27
72
|
Sparse fieldset:
|
|
28
73
|
- fields: names of the attributes and/or relationships to include in each record, as an
|
|
@@ -67,7 +112,10 @@ class McpToolkit::Authority::Tools::List < McpToolkit::Authority::Tools::Base
|
|
|
67
112
|
"field. Include \"id\" if you need it. Unknown names are rejected."
|
|
68
113
|
}
|
|
69
114
|
},
|
|
70
|
-
required: ["resource"]
|
|
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
|
|
71
119
|
}
|
|
72
120
|
)
|
|
73
121
|
|
|
@@ -16,7 +16,14 @@ class McpToolkit::Authority::Tools::ResourceSchema < McpToolkit::Authority::Tool
|
|
|
16
16
|
- relationships: associated resources emitted in the record's `links`; each names the
|
|
17
17
|
`target_resource` it resolves to (callable via `list`/`get`)
|
|
18
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
|
|
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.
|
|
20
27
|
The `attributes` and `relationships` names are also the valid values for the `fields` sparse
|
|
21
28
|
fieldset argument on `get` / `list`. Call this before `list` to learn a resource's shape.
|
|
22
29
|
DESC
|
|
@@ -34,7 +41,8 @@ class McpToolkit::Authority::Tools::ResourceSchema < McpToolkit::Authority::Tool
|
|
|
34
41
|
}
|
|
35
42
|
)
|
|
36
43
|
|
|
37
|
-
def call(context:, resource: nil, **
|
|
44
|
+
def call(context:, resource: nil, **extra)
|
|
45
|
+
reject_unknown_arguments!(extra.except(:account_id))
|
|
38
46
|
descriptor = resolve_descriptor(resource)
|
|
39
47
|
ensure_resource_accessible!(descriptor, context)
|
|
40
48
|
ensure_scope!(descriptor, context)
|
|
@@ -11,15 +11,23 @@ class McpToolkit::Authority::Tools::Resources < McpToolkit::Authority::Tools::Ba
|
|
|
11
11
|
tool_name "resources"
|
|
12
12
|
description <<~DESC.strip
|
|
13
13
|
List all read-only resources available via the `list` and `get` tools. Returns each
|
|
14
|
-
resource's name
|
|
15
|
-
|
|
16
|
-
|
|
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.
|
|
17
18
|
DESC
|
|
18
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.
|
|
19
22
|
def call(context:, **_args)
|
|
20
23
|
{
|
|
21
24
|
resources: visible_resources(context).map do |resource|
|
|
22
|
-
{
|
|
25
|
+
{
|
|
26
|
+
name: resource.name,
|
|
27
|
+
description: resource.description,
|
|
28
|
+
filterable: filterable?(resource),
|
|
29
|
+
note: resource.note
|
|
30
|
+
}.compact
|
|
23
31
|
end
|
|
24
32
|
}
|
|
25
33
|
end
|
|
@@ -30,4 +38,18 @@ class McpToolkit::Authority::Tools::Resources < McpToolkit::Authority::Tools::Ba
|
|
|
30
38
|
def visible_resources(context)
|
|
31
39
|
registry.resources.reject { |resource| resource.superusers_only? && !context.superuser? }
|
|
32
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
|
|
33
55
|
end
|