mcp_toolkit 0.3.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 +7 -0
- data/.github/workflows/ci.yml +43 -0
- data/.rubocop.yml +98 -0
- data/CHANGELOG.md +123 -0
- data/LICENSE.txt +21 -0
- data/README.md +301 -0
- data/Rakefile +21 -0
- data/app/controllers/mcp_toolkit/server_controller.rb +19 -0
- data/config/routes.rb +18 -0
- data/lib/mcp_toolkit/auth/authenticator.rb +99 -0
- data/lib/mcp_toolkit/auth/authority.rb +75 -0
- data/lib/mcp_toolkit/auth/authority_server_client.rb +50 -0
- data/lib/mcp_toolkit/auth/introspection.rb +162 -0
- data/lib/mcp_toolkit/configuration.rb +209 -0
- data/lib/mcp_toolkit/engine.rb +21 -0
- data/lib/mcp_toolkit/errors/base.rb +10 -0
- data/lib/mcp_toolkit/errors/configuration_error.rb +5 -0
- data/lib/mcp_toolkit/errors/invalid_params.rb +5 -0
- data/lib/mcp_toolkit/errors/unauthorized.rb +5 -0
- data/lib/mcp_toolkit/field_selection.rb +93 -0
- data/lib/mcp_toolkit/filtering.rb +152 -0
- data/lib/mcp_toolkit/get_executor.rb +32 -0
- data/lib/mcp_toolkit/list_executor.rb +137 -0
- data/lib/mcp_toolkit/registry.rb +67 -0
- data/lib/mcp_toolkit/resource.rb +129 -0
- data/lib/mcp_toolkit/resource_schema.rb +163 -0
- data/lib/mcp_toolkit/serialization.rb +62 -0
- data/lib/mcp_toolkit/serializer/base.rb +285 -0
- data/lib/mcp_toolkit/server.rb +44 -0
- data/lib/mcp_toolkit/session.rb +46 -0
- data/lib/mcp_toolkit/sql_sanitizer.rb +14 -0
- data/lib/mcp_toolkit/token_kinds.rb +14 -0
- data/lib/mcp_toolkit/tools/base.rb +100 -0
- data/lib/mcp_toolkit/tools/get.rb +57 -0
- data/lib/mcp_toolkit/tools/list.rb +83 -0
- data/lib/mcp_toolkit/tools/resource_schema.rb +40 -0
- data/lib/mcp_toolkit/tools/resources.rb +27 -0
- data/lib/mcp_toolkit/transport/controller_methods.rb +226 -0
- data/lib/mcp_toolkit/unknown_resource_message.rb +75 -0
- data/lib/mcp_toolkit/version.rb +5 -0
- data/lib/mcp_toolkit.rb +118 -0
- data/sig/mcp_toolkit.rbs +4 -0
- metadata +147 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Base class for the generic MCP tools. Subclasses an official-SDK `MCP::Tool`,
|
|
4
|
+
# so `name`/`description`/`input_schema` and the `call` contract are the gem's.
|
|
5
|
+
# This base adds the shared concern every tool needs: authenticating +
|
|
6
|
+
# scope-resolving the caller (via McpToolkit::Auth::Authenticator) before
|
|
7
|
+
# running, and turning tool-level errors into `isError: true` MCP results
|
|
8
|
+
# (rather than letting them become JSON-RPC protocol errors).
|
|
9
|
+
#
|
|
10
|
+
# The bearer token, JSON-RPC `_meta`, and the account-id header are threaded in
|
|
11
|
+
# through `server_context` (set per-request by the controller). The active
|
|
12
|
+
# McpToolkit config is also threaded in as `server_context[:mcp_config]` so a
|
|
13
|
+
# process can, in principle, host more than one configured server; it falls back
|
|
14
|
+
# to `McpToolkit.config`.
|
|
15
|
+
class McpToolkit::Tools::Base < MCP::Tool
|
|
16
|
+
# Runs `block` with an authenticated, scoped context, serializing any
|
|
17
|
+
# McpToolkit::Errors into a clean text tool error.
|
|
18
|
+
#
|
|
19
|
+
# The resolved `scope_root` is yielded — it is the tools' serializer `scope`
|
|
20
|
+
# AND the root every query is scoped through.
|
|
21
|
+
#
|
|
22
|
+
# `account_id` is the superuser account selector arriving as a tool
|
|
23
|
+
# argument (the gem passes tool args as kwargs, not via server_context),
|
|
24
|
+
# threaded here so it joins `_meta` / the header in the resolution order.
|
|
25
|
+
#
|
|
26
|
+
# `required_scope` is the explicitly-declared scope a token must carry (the
|
|
27
|
+
# caller resolves it from the resource — see Registry#required_scope_for).
|
|
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)
|
|
30
|
+
config = config_from(server_context)
|
|
31
|
+
context = McpToolkit::Auth::Authenticator.call(
|
|
32
|
+
token: server_context[:bearer_token],
|
|
33
|
+
meta: meta_from(server_context),
|
|
34
|
+
arguments: { "account_id" => account_id }.compact,
|
|
35
|
+
header_account_id: server_context[:header_account_id],
|
|
36
|
+
config:
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
unless context.introspection.authorized_for_scope?(required_scope)
|
|
40
|
+
return error_response("Unauthorized: token lacks the #{required_scope.inspect} scope")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
text_response(yield(context.scope_root))
|
|
44
|
+
rescue McpToolkit::Errors::Unauthorized => e
|
|
45
|
+
error_response("Unauthorized: #{e.message}")
|
|
46
|
+
rescue McpToolkit::Errors::InvalidParams => e
|
|
47
|
+
error_response("Invalid request: #{e.message}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Authenticates the token (valid + the explicitly-declared `required_scope`)
|
|
51
|
+
# WITHOUT requiring an account selection. Used by the schema-discovery tools,
|
|
52
|
+
# which reveal shape, not tenant data, so a superuser shouldn't have to pin an
|
|
53
|
+
# account just to discover what exists. Empty/nil `required_scope` => no scope
|
|
54
|
+
# check.
|
|
55
|
+
def self.with_authentication(server_context, required_scope: nil)
|
|
56
|
+
config = config_from(server_context)
|
|
57
|
+
introspection = McpToolkit::Auth::Introspection.call(server_context[:bearer_token], config:)
|
|
58
|
+
return error_response("Unauthorized: invalid or expired token") unless introspection.valid?
|
|
59
|
+
|
|
60
|
+
unless introspection.authorized_for_scope?(required_scope)
|
|
61
|
+
return error_response("Unauthorized: token lacks the #{required_scope.inspect} scope")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
text_response(yield)
|
|
65
|
+
rescue McpToolkit::Errors::InvalidParams => e
|
|
66
|
+
error_response("Invalid request: #{e.message}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.text_response(payload)
|
|
70
|
+
text = payload.is_a?(String) ? payload : JSON.generate(payload)
|
|
71
|
+
MCP::Tool::Response.new([{ type: "text", text: }])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.error_response(message)
|
|
75
|
+
MCP::Tool::Response.new([{ type: "text", text: message }], error: true)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The gem nests the request `_meta` under server_context[:_meta].
|
|
79
|
+
def self.meta_from(server_context)
|
|
80
|
+
server_context[:_meta] || {}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.config_from(server_context)
|
|
84
|
+
server_context[:mcp_config] || McpToolkit.config
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.lookup_resource(name, config)
|
|
88
|
+
config.registry.fetch(name)
|
|
89
|
+
rescue McpToolkit::Registry::UnknownResource => e
|
|
90
|
+
raise McpToolkit::Errors::InvalidParams, e.message
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Validates the `resource` argument is present and resolves its descriptor,
|
|
94
|
+
# raising InvalidParams (=> clean tool error) for a blank or unknown resource.
|
|
95
|
+
def self.resolve_descriptor(name, config)
|
|
96
|
+
raise McpToolkit::Errors::InvalidParams, "resource is required" if name.to_s.strip.empty?
|
|
97
|
+
|
|
98
|
+
lookup_resource(name, config)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Fetches a single record by id from a registered resource, scoped to the
|
|
4
|
+
# resolved scope root.
|
|
5
|
+
class McpToolkit::Tools::Get < McpToolkit::Tools::Base
|
|
6
|
+
tool_name "get"
|
|
7
|
+
description <<~DESC.strip
|
|
8
|
+
Fetch a single record by id from a read-only resource. Pass the resource name as `resource`
|
|
9
|
+
and the record id as `id`. Use the `resources` tool to discover available resources.
|
|
10
|
+
|
|
11
|
+
For tokens that span multiple accounts (superuser), pass `account_id` to pin the active
|
|
12
|
+
account; account-scoped tokens may omit it. The response mirrors the resource's record
|
|
13
|
+
shape (attributes + a `links` block).
|
|
14
|
+
|
|
15
|
+
Pass `fields` to return a sparse fieldset — the attributes and/or relationships you name
|
|
16
|
+
(as an array or comma-separated string), omitting everything else. Include "id" if you need
|
|
17
|
+
it. Valid names come from the resource's `resource_schema`; unknown names are rejected.
|
|
18
|
+
DESC
|
|
19
|
+
|
|
20
|
+
input_schema(
|
|
21
|
+
properties: {
|
|
22
|
+
resource: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "Resource name (use the `resources` tool to discover valid values)"
|
|
25
|
+
},
|
|
26
|
+
# The id type is left open so a string/UUID primary key works as well as an
|
|
27
|
+
# integer one; the record is looked up by the value as given, uncoerced.
|
|
28
|
+
id: { type: %w[string integer], description: "The record ID (integer or string/UUID)" },
|
|
29
|
+
account_id: {
|
|
30
|
+
type: "integer",
|
|
31
|
+
description: "Account to operate on. Required for superuser tokens; ignored otherwise."
|
|
32
|
+
},
|
|
33
|
+
fields: {
|
|
34
|
+
type: %w[array string],
|
|
35
|
+
items: { type: "string" },
|
|
36
|
+
description: "Sparse fieldset — names of attributes and/or relationships to include, as " \
|
|
37
|
+
"an array or a comma-separated string. Omit to return every field. Include " \
|
|
38
|
+
"\"id\" if you need it. Unknown names are rejected; see the resource's " \
|
|
39
|
+
"`resource_schema` for valid attribute and relationship names."
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
required: %w[resource id]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def self.call(server_context:, resource: nil, id: nil, account_id: nil, fields: nil, **_args)
|
|
46
|
+
config = config_from(server_context)
|
|
47
|
+
# Resolve the resource FIRST so its effective required scope is known before
|
|
48
|
+
# the scope check (and so an unknown resource is a clean tool error).
|
|
49
|
+
descriptor = resolve_descriptor(resource, config)
|
|
50
|
+
required_scope = config.registry.required_scope_for(descriptor)
|
|
51
|
+
with_account(server_context, account_id:, required_scope:) do |scope_root|
|
|
52
|
+
McpToolkit::GetExecutor.call(resource: descriptor, scope_root:, id:, fields:)
|
|
53
|
+
end
|
|
54
|
+
rescue McpToolkit::Errors::InvalidParams => e
|
|
55
|
+
error_response("Invalid request: #{e.message}")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Fetches a paginated list of records from a registered resource, scoped to the
|
|
4
|
+
# resolved scope root.
|
|
5
|
+
class McpToolkit::Tools::List < McpToolkit::Tools::Base
|
|
6
|
+
tool_name "list"
|
|
7
|
+
description <<~DESC.strip
|
|
8
|
+
Fetch a paginated list of records from a read-only resource. Pass the resource name as
|
|
9
|
+
`resource`. Use the `resources` tool to discover resources and `resource_schema` to learn a
|
|
10
|
+
resource's shape.
|
|
11
|
+
|
|
12
|
+
Standard filters:
|
|
13
|
+
- ids: comma-separated list of IDs to fetch
|
|
14
|
+
- updated_since: ISO 8601 timestamp; only records updated after this time
|
|
15
|
+
- limit: page size (default 25, max 100)
|
|
16
|
+
- offset: pagination offset (default 0)
|
|
17
|
+
|
|
18
|
+
Per-attribute equality filters:
|
|
19
|
+
- filter: an object of { <key>: <value> } exact-match filters, applied ON TOP of the
|
|
20
|
+
account scope (they can only narrow, never widen). Each resource advertises its
|
|
21
|
+
available filter keys via `resource_schema` (the `filters` array). Unknown keys are
|
|
22
|
+
rejected.
|
|
23
|
+
|
|
24
|
+
Sparse fieldset:
|
|
25
|
+
- fields: names of the attributes and/or relationships to include in each record, as an
|
|
26
|
+
array or a comma-separated string. Omit to return every field. Narrowing the set shrinks
|
|
27
|
+
the response (and skips loading unselected relationships) — prefer it when you only need a
|
|
28
|
+
few fields. Include "id" if you need it. Valid names come from a resource's
|
|
29
|
+
`resource_schema` (its `attributes` and `relationships`); unknown names are rejected.
|
|
30
|
+
|
|
31
|
+
For tokens that span multiple accounts (superuser), pass `account_id` to pin the active
|
|
32
|
+
account; account-scoped tokens may omit it. The response shape is
|
|
33
|
+
{ "<resource>": [...], "meta": { total_count, limit, offset } }.
|
|
34
|
+
DESC
|
|
35
|
+
|
|
36
|
+
input_schema(
|
|
37
|
+
properties: {
|
|
38
|
+
resource: {
|
|
39
|
+
type: "string",
|
|
40
|
+
description: "Resource name (use the `resources` tool to discover valid values)"
|
|
41
|
+
},
|
|
42
|
+
account_id: {
|
|
43
|
+
type: "integer",
|
|
44
|
+
description: "Account to operate on. Required for superuser tokens; ignored otherwise."
|
|
45
|
+
},
|
|
46
|
+
ids: { type: "string", description: "Comma-separated list of IDs to fetch" },
|
|
47
|
+
updated_since: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "ISO 8601 timestamp; only records updated after this time"
|
|
50
|
+
},
|
|
51
|
+
filter: {
|
|
52
|
+
type: "object",
|
|
53
|
+
description: "Per-attribute exact-match equality filters, e.g. { \"booking_id\": 42 }. " \
|
|
54
|
+
"See a resource's `resource_schema` `filters` for the keys it accepts.",
|
|
55
|
+
additionalProperties: true
|
|
56
|
+
},
|
|
57
|
+
limit: { type: "integer", description: "Page size (default 25, max 100)" },
|
|
58
|
+
offset: { type: "integer", description: "Pagination offset (default 0)" },
|
|
59
|
+
fields: {
|
|
60
|
+
type: %w[array string],
|
|
61
|
+
items: { type: "string" },
|
|
62
|
+
description: "Sparse fieldset — names of attributes and/or relationships to include in " \
|
|
63
|
+
"each record, as an array or a comma-separated string. Omit to return every " \
|
|
64
|
+
"field. Include \"id\" if you need it. Unknown names are rejected; see a " \
|
|
65
|
+
"resource's `resource_schema` for valid attribute and relationship names."
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
required: ["resource"]
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def self.call(server_context:, resource: nil, account_id: nil, **params)
|
|
72
|
+
config = config_from(server_context)
|
|
73
|
+
# Resolve the resource FIRST so its effective required scope is known before
|
|
74
|
+
# the scope check (and so an unknown resource is a clean tool error).
|
|
75
|
+
descriptor = resolve_descriptor(resource, config)
|
|
76
|
+
required_scope = config.registry.required_scope_for(descriptor)
|
|
77
|
+
with_account(server_context, account_id:, required_scope:) do |scope_root|
|
|
78
|
+
McpToolkit::ListExecutor.call(resource: descriptor, scope_root:, params:)
|
|
79
|
+
end
|
|
80
|
+
rescue McpToolkit::Errors::InvalidParams => e
|
|
81
|
+
error_response("Invalid request: #{e.message}")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Discovery tool: the detailed schema (attributes + relationships) of one
|
|
4
|
+
# resource.
|
|
5
|
+
class McpToolkit::Tools::ResourceSchema < McpToolkit::Tools::Base
|
|
6
|
+
tool_name "resource_schema"
|
|
7
|
+
description <<~DESC.strip
|
|
8
|
+
Describe a single read-only resource in detail. Pass the resource name as `resource` (use
|
|
9
|
+
the `resources` tool to discover names). Returns:
|
|
10
|
+
- attributes: every field in the response, each with its `type` and a value `format` hint
|
|
11
|
+
- relationships: associated resources emitted in the record's `links`; each names the
|
|
12
|
+
`target_resource` it resolves to (callable via `list`/`get`)
|
|
13
|
+
- 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
|
+
The `attributes` and `relationships` names are also the valid values for the `fields` sparse
|
|
16
|
+
fieldset argument on `get` / `list`. Call this before `list` to learn a resource's shape.
|
|
17
|
+
DESC
|
|
18
|
+
|
|
19
|
+
input_schema(
|
|
20
|
+
properties: {
|
|
21
|
+
resource: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "Resource name (use the `resources` tool to discover valid values)"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
required: ["resource"]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def self.call(server_context:, resource: nil, **_args)
|
|
30
|
+
config = config_from(server_context)
|
|
31
|
+
# Resolve the resource FIRST so its effective required scope gates discovery
|
|
32
|
+
# of THIS resource's shape (and an unknown resource is a clean tool error).
|
|
33
|
+
descriptor = resolve_descriptor(resource, config)
|
|
34
|
+
with_authentication(server_context, required_scope: config.registry.required_scope_for(descriptor)) do
|
|
35
|
+
McpToolkit::ResourceSchema.call(descriptor, registry: config.registry)
|
|
36
|
+
end
|
|
37
|
+
rescue McpToolkit::Errors::InvalidParams => e
|
|
38
|
+
error_response("Invalid request: #{e.message}")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Discovery tool: lists every read-only resource exposed by this server.
|
|
4
|
+
class McpToolkit::Tools::Resources < McpToolkit::Tools::Base
|
|
5
|
+
tool_name "resources"
|
|
6
|
+
description <<~DESC.strip
|
|
7
|
+
List all read-only resources available via the `list` and `get` tools. Returns each
|
|
8
|
+
resource's name and a short description. Call this once at the start of a session to learn
|
|
9
|
+
what exists, then use `resource_schema` for a specific resource's attributes and
|
|
10
|
+
relationships.
|
|
11
|
+
DESC
|
|
12
|
+
|
|
13
|
+
input_schema(properties: {})
|
|
14
|
+
|
|
15
|
+
def self.call(server_context:, **_args)
|
|
16
|
+
config = config_from(server_context)
|
|
17
|
+
# Discovery requires the registry-level default scope (the satellite's
|
|
18
|
+
# app-wide scope); per-resource scopes are enforced by `get` / `list`.
|
|
19
|
+
with_authentication(server_context, required_scope: config.registry.default_required_permissions_scope) do
|
|
20
|
+
{
|
|
21
|
+
resources: config.registry.resources.map do |resource|
|
|
22
|
+
{ name: resource.name, description: resource.description }
|
|
23
|
+
end
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
# The MCP Streamable-HTTP transport, provided as an includable concern. An
|
|
6
|
+
# app's controller includes this to get the full transport with no per-app code:
|
|
7
|
+
#
|
|
8
|
+
# class Mcp::ServerController < ApplicationController
|
|
9
|
+
# include McpToolkit::Transport::ControllerMethods
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# and routes the four endpoints at its actions:
|
|
13
|
+
#
|
|
14
|
+
# post "mcp", to: "mcp/server#create"
|
|
15
|
+
# get "mcp", to: "mcp/server#stream"
|
|
16
|
+
# delete "mcp", to: "mcp/server#destroy"
|
|
17
|
+
# get "mcp/health", to: "mcp/server#health"
|
|
18
|
+
#
|
|
19
|
+
# Endpoints
|
|
20
|
+
# POST /mcp - JSON-RPC requests/responses
|
|
21
|
+
# GET /mcp - server-initiated SSE stream (none emitted; 405)
|
|
22
|
+
# DELETE /mcp - terminate the current session
|
|
23
|
+
# GET /mcp/health - unauthenticated health probe
|
|
24
|
+
#
|
|
25
|
+
# Authentication
|
|
26
|
+
# The bearer token is NOT verified at the transport boundary; it is forwarded
|
|
27
|
+
# into each tool, which authenticates it against the central app's
|
|
28
|
+
# introspection endpoint (McpToolkit::Auth::Authenticator). The transport only
|
|
29
|
+
# requires that *a* token be present (so unauthenticated calls are refused
|
|
30
|
+
# before any work). Tools resolve the active account from `_meta` /
|
|
31
|
+
# `account_id` argument / the account-id header.
|
|
32
|
+
#
|
|
33
|
+
# Session lifecycle
|
|
34
|
+
# - First `initialize` POST: create a session, return its id in the
|
|
35
|
+
# `Mcp-Session-Id` response header. The client echoes it on later requests.
|
|
36
|
+
# - Subsequent POSTs: validate the session id; missing/expired => 404.
|
|
37
|
+
# - DELETE /mcp ends the session.
|
|
38
|
+
#
|
|
39
|
+
# Notifications (requests without an `id`) get a 202 with no body.
|
|
40
|
+
#
|
|
41
|
+
# Overridable hooks
|
|
42
|
+
# - `mcp_config` -> the McpToolkit::Configuration to use (default: McpToolkit.config)
|
|
43
|
+
# - `mcp_extra_tools` -> Array of additional MCP::Tool subclasses (default: [])
|
|
44
|
+
#
|
|
45
|
+
# CSRF: the concern disables forgery protection (this is a token-authenticated
|
|
46
|
+
# JSON API). Inherit from ActionController::Base (not ::API) if your app's
|
|
47
|
+
# controller stack needs helper_method, as bsa-notifications does.
|
|
48
|
+
module McpToolkit::Transport::ControllerMethods
|
|
49
|
+
extend ActiveSupport::Concern
|
|
50
|
+
|
|
51
|
+
SESSION_HEADER = "Mcp-Session-Id"
|
|
52
|
+
|
|
53
|
+
included do
|
|
54
|
+
protect_from_forgery with: :null_session if respond_to?(:protect_from_forgery)
|
|
55
|
+
|
|
56
|
+
before_action :mcp_require_token!, except: [:health]
|
|
57
|
+
before_action :mcp_resolve_session!, only: [:create]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def create
|
|
61
|
+
request_body = mcp_parsed_body
|
|
62
|
+
server = McpToolkit::Server.build(
|
|
63
|
+
server_context: mcp_server_context,
|
|
64
|
+
config: mcp_config,
|
|
65
|
+
extra_tools: mcp_extra_tools
|
|
66
|
+
)
|
|
67
|
+
response_json = server.handle_json(JSON.generate(request_body))
|
|
68
|
+
|
|
69
|
+
# handle_json returns nil for notifications (no id) -> 202 Accepted, no body.
|
|
70
|
+
return head :accepted if response_json.nil?
|
|
71
|
+
|
|
72
|
+
mcp_render_response(response_json)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# GET /mcp - no server-initiated SSE stream is emitted; 405 per MCP spec.
|
|
76
|
+
def stream
|
|
77
|
+
head :method_not_allowed
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# DELETE /mcp - terminate the current session.
|
|
81
|
+
def destroy
|
|
82
|
+
McpToolkit::Session.delete(request.headers[SESSION_HEADER], config: mcp_config)
|
|
83
|
+
head :no_content
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# GET /mcp/health - unauthenticated probe.
|
|
87
|
+
def health
|
|
88
|
+
render json: {
|
|
89
|
+
status: "ok",
|
|
90
|
+
server: mcp_config.server_name,
|
|
91
|
+
version: mcp_config.server_version
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# ---- overridable hooks ----------------------------------------------
|
|
98
|
+
|
|
99
|
+
def mcp_config
|
|
100
|
+
McpToolkit.config
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def mcp_extra_tools
|
|
104
|
+
[]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Logger for transport-level diagnostics. Defaults to Rails.logger when running
|
|
108
|
+
# inside Rails, and to a $stdout logger otherwise (e.g. the gem's own unit suite)
|
|
109
|
+
# so a diagnostic is never silently dropped. Overridable so a host can inject its
|
|
110
|
+
# own; never returns nil.
|
|
111
|
+
def mcp_logger
|
|
112
|
+
return Rails.logger if defined?(Rails)
|
|
113
|
+
|
|
114
|
+
@mcp_logger ||= Logger.new($stdout)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# ---- per-request context --------------------------------------------
|
|
118
|
+
|
|
119
|
+
# Per-request context threaded to the tools. The gem merges the request's
|
|
120
|
+
# `_meta` into this hash (as `:_meta`) at tool-call time.
|
|
121
|
+
def mcp_server_context
|
|
122
|
+
{
|
|
123
|
+
bearer_token: mcp_extract_token,
|
|
124
|
+
header_account_id: request.headers[mcp_config.account_id_header].presence,
|
|
125
|
+
mcp_config: mcp_config
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Renders the JSON-RPC payload as application/json by default, or as a single
|
|
130
|
+
# SSE `message` frame when the client's Accept header asks for it. We never
|
|
131
|
+
# actually stream (one message + EOF) so a strict client interoperates either
|
|
132
|
+
# way.
|
|
133
|
+
def mcp_render_response(response_json)
|
|
134
|
+
if mcp_event_stream_requested?
|
|
135
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
136
|
+
body = "event: message\ndata: #{response_json}\n\n"
|
|
137
|
+
render body:, content_type: Mime::Type.lookup("text/event-stream").to_s
|
|
138
|
+
else
|
|
139
|
+
render json: response_json
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def mcp_event_stream_requested?
|
|
144
|
+
request.headers["Accept"].to_s.include?("text/event-stream")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# ---- auth (presence only; real auth is per-tool) --------------------
|
|
148
|
+
|
|
149
|
+
# A token must be present; its validity is enforced per-tool. Extraction
|
|
150
|
+
# order: Bearer header, then X-MCP-Token, then ?token=.
|
|
151
|
+
def mcp_require_token!
|
|
152
|
+
return if mcp_extract_token.present?
|
|
153
|
+
|
|
154
|
+
mcp_render_unauthorized("Missing authorization token")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def mcp_extract_token
|
|
158
|
+
auth_header = request.headers["Authorization"]
|
|
159
|
+
return auth_header.sub("Bearer ", "") if auth_header&.start_with?("Bearer ")
|
|
160
|
+
|
|
161
|
+
request.headers["X-MCP-Token"].presence || params[:token].presence
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# ---- session lifecycle ----------------------------------------------
|
|
165
|
+
|
|
166
|
+
# POST: create a session on `initialize`, otherwise require an existing one.
|
|
167
|
+
def mcp_resolve_session!
|
|
168
|
+
methods = mcp_methods_from(mcp_parsed_body)
|
|
169
|
+
|
|
170
|
+
if methods.include?("initialize")
|
|
171
|
+
@mcp_session = McpToolkit::Session.create!(config: mcp_config)
|
|
172
|
+
else
|
|
173
|
+
@mcp_session = McpToolkit::Session.find(request.headers[SESSION_HEADER], config: mcp_config)
|
|
174
|
+
return mcp_render_session_not_found if @mcp_session.nil?
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
response.headers[SESSION_HEADER] = @mcp_session.id
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def mcp_methods_from(request_body)
|
|
181
|
+
# Array.wrap (not Kernel#Array): a single JSON-RPC Hash must wrap to
|
|
182
|
+
# [hash], whereas Kernel#Array(hash) would explode it into [[k, v], ...].
|
|
183
|
+
Array.wrap(request_body).filter_map { |req| req.is_a?(Hash) ? req["method"] : nil }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def mcp_parsed_body
|
|
187
|
+
return @mcp_parsed_body if defined?(@mcp_parsed_body)
|
|
188
|
+
|
|
189
|
+
@mcp_parsed_body = JSON.parse(request.body.read)
|
|
190
|
+
rescue JSON::ParserError
|
|
191
|
+
@mcp_parsed_body = {}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# ---- error renders ---------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def mcp_render_unauthorized(message)
|
|
197
|
+
render json: {
|
|
198
|
+
jsonrpc: "2.0",
|
|
199
|
+
id: nil,
|
|
200
|
+
error: { code: -32_000, message: "Unauthorized: #{message}" }
|
|
201
|
+
}, status: :unauthorized
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def mcp_render_session_not_found
|
|
205
|
+
mcp_log_session_not_found
|
|
206
|
+
render json: {
|
|
207
|
+
jsonrpc: "2.0",
|
|
208
|
+
id: nil,
|
|
209
|
+
error: { code: -32_001, message: "Session not found or expired" }
|
|
210
|
+
}, status: :not_found
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Warns (greppable, no id/token) when a POST arrives with no matching session.
|
|
214
|
+
# The common cause is a session created on one process but looked up on another
|
|
215
|
+
# because `cache_store` isn't a shared store — invisible otherwise, since the
|
|
216
|
+
# caller just sees a 404. Records only whether a session-id header was PRESENT so
|
|
217
|
+
# a header-missing client bug is distinguishable from a cache misconfiguration.
|
|
218
|
+
def mcp_log_session_not_found
|
|
219
|
+
header_present = request.headers[SESSION_HEADER].present?
|
|
220
|
+
mcp_logger.warn(
|
|
221
|
+
"[McpToolkit] MCP session not found or expired " \
|
|
222
|
+
"(#{SESSION_HEADER} header present: #{header_present}). If sessions are created but not " \
|
|
223
|
+
"found, cache_store is likely not shared across processes (set it to a shared store, e.g. Rails.cache)."
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "did_you_mean"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
nil
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Builds the McpToolkit::Registry::UnknownResource message from a bad name and the
|
|
10
|
+
# registered resource names: it states the bad name, then (a) suggests the nearest
|
|
11
|
+
# registered name(s) for a near-miss — via Ruby's stdlib DidYouMean spell checker
|
|
12
|
+
# when available, else a dependency-free prefix/substring + edit-distance fallback —
|
|
13
|
+
# and (b), when the catalog is short, lists them all, so a caller (typically an MCP
|
|
14
|
+
# agent that guessed a name) can self-correct without another round-trip to the
|
|
15
|
+
# `resources` tool.
|
|
16
|
+
class McpToolkit::UnknownResourceMessage
|
|
17
|
+
FULL_LIST_MAX = 10
|
|
18
|
+
MAX_SUGGESTIONS = 3
|
|
19
|
+
|
|
20
|
+
def initialize(name, resource_names)
|
|
21
|
+
@name = name
|
|
22
|
+
@resource_names = resource_names
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build
|
|
26
|
+
message = "unknown resource: #{@name.inspect}"
|
|
27
|
+
return message if @resource_names.empty?
|
|
28
|
+
|
|
29
|
+
suggestions = suggestions_for(@name.to_s, @resource_names)
|
|
30
|
+
message += ". Did you mean #{quote_join(suggestions)}?" if suggestions.any?
|
|
31
|
+
message += " Registered resources: #{quote_join(@resource_names.sort)}." if @resource_names.size <= FULL_LIST_MAX
|
|
32
|
+
message
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def suggestions_for(name, names)
|
|
38
|
+
if defined?(DidYouMean::SpellChecker)
|
|
39
|
+
Array(DidYouMean::SpellChecker.new(dictionary: names).correct(name)).first(MAX_SUGGESTIONS)
|
|
40
|
+
else
|
|
41
|
+
fallback_suggestions(name, names)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fallback_suggestions(name, names)
|
|
46
|
+
target = name.downcase
|
|
47
|
+
matches = names.select { |candidate| near_miss?(candidate.downcase, target) }
|
|
48
|
+
matches.sort_by { |candidate| levenshtein(candidate.downcase, target) }.first(MAX_SUGGESTIONS)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def near_miss?(candidate, target)
|
|
52
|
+
candidate.start_with?(target) || target.start_with?(candidate) ||
|
|
53
|
+
candidate.include?(target) || levenshtein(candidate, target) <= 2
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def levenshtein(source, target)
|
|
57
|
+
return target.length if source.empty?
|
|
58
|
+
return source.length if target.empty?
|
|
59
|
+
|
|
60
|
+
previous = (0..target.length).to_a
|
|
61
|
+
source.each_char.with_index do |source_char, row|
|
|
62
|
+
current = [row + 1]
|
|
63
|
+
target.each_char.with_index do |target_char, col|
|
|
64
|
+
cost = source_char == target_char ? 0 : 1
|
|
65
|
+
current << [previous[col + 1] + 1, current[col] + 1, previous[col] + cost].min
|
|
66
|
+
end
|
|
67
|
+
previous = current
|
|
68
|
+
end
|
|
69
|
+
previous.last
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def quote_join(names)
|
|
73
|
+
names.map { |name| "\"#{name}\"" }.join(", ")
|
|
74
|
+
end
|
|
75
|
+
end
|