connectors 0.1.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/CHANGELOG.md +35 -0
- data/CONNECTORS_FRAMEWORK.md +799 -0
- data/CONTRIBUTING.md +60 -0
- data/MCP_CLIENT.md +168 -0
- data/MIT-LICENSE +20 -0
- data/README.md +146 -0
- data/app/connectors/clickup/connector.rb +13 -0
- data/app/connectors/gmail/api.rb +114 -0
- data/app/connectors/gmail/connector.rb +921 -0
- data/app/connectors/gmail/mime_builder.rb +261 -0
- data/app/connectors/gmail/mime_parser.rb +106 -0
- data/app/connectors/gmail/polling.rb +154 -0
- data/app/connectors/remote_mcp/connector.rb +18 -0
- data/app/connectors/resend/connector.rb +218 -0
- data/app/controllers/concerns/connectors/grant_access.rb +43 -0
- data/app/controllers/connectors/actions_controller.rb +66 -0
- data/app/controllers/connectors/application_controller.rb +5 -0
- data/app/controllers/connectors/credentials_controller.rb +245 -0
- data/app/controllers/connectors/grants_controller.rb +123 -0
- data/app/controllers/connectors/mcp_controller.rb +88 -0
- data/app/controllers/connectors/oauth_controller.rb +134 -0
- data/app/controllers/connectors/types_controller.rb +144 -0
- data/app/controllers/connectors/webhooks_controller.rb +105 -0
- data/app/jobs/connectors/application_job.rb +4 -0
- data/app/jobs/connectors/deliver_webhook_job.rb +27 -0
- data/app/jobs/connectors/poll_job.rb +49 -0
- data/app/models/connectors/application_record.rb +5 -0
- data/app/models/connectors/credential_share.rb +31 -0
- data/app/models/connectors/grant.rb +103 -0
- data/app/models/connectors/mcp_authorization.rb +6 -0
- data/app/models/connectors/mcp_interaction.rb +6 -0
- data/app/models/connectors/poll_state.rb +17 -0
- data/app/models/connectors/webhook_event.rb +19 -0
- data/config/routes.rb +68 -0
- data/db/migrate/20260518210324_create_connectors_grants.rb +49 -0
- data/db/migrate/20260518214609_create_connectors_webhook_events.rb +35 -0
- data/db/migrate/20260521140000_create_connectors_credential_shares.rb +26 -0
- data/db/migrate/20260922120000_create_connectors_poll_states.rb +12 -0
- data/db/migrate/20260922130000_create_connectors_mcp_transactions.rb +18 -0
- data/docs/adding-connectors.md +92 -0
- data/docs/architecture.md +71 -0
- data/docs/releasing.md +60 -0
- data/lib/connectors/action.rb +90 -0
- data/lib/connectors/action_builder.rb +125 -0
- data/lib/connectors/action_runner.rb +99 -0
- data/lib/connectors/auth/scheme/api_key.rb +51 -0
- data/lib/connectors/auth/scheme/oauth2.rb +23 -0
- data/lib/connectors/auth/scheme.rb +35 -0
- data/lib/connectors/auth.rb +4 -0
- data/lib/connectors/auth_injection.rb +65 -0
- data/lib/connectors/client_builder.rb +87 -0
- data/lib/connectors/configuration.rb +138 -0
- data/lib/connectors/connector.rb +565 -0
- data/lib/connectors/credential_schema.rb +219 -0
- data/lib/connectors/credential_tester.rb +85 -0
- data/lib/connectors/credential_type_registry.rb +162 -0
- data/lib/connectors/credential_types/http_auth.rb +171 -0
- data/lib/connectors/engine.rb +123 -0
- data/lib/connectors/errors.rb +88 -0
- data/lib/connectors/grant_policy.rb +13 -0
- data/lib/connectors/mcp/access.rb +50 -0
- data/lib/connectors/mcp/authorization.rb +177 -0
- data/lib/connectors/mcp/authorization_context.rb +25 -0
- data/lib/connectors/mcp/authorization_discovery.rb +42 -0
- data/lib/connectors/mcp/cancellation.rb +36 -0
- data/lib/connectors/mcp/client.rb +137 -0
- data/lib/connectors/mcp/connection_config.rb +48 -0
- data/lib/connectors/mcp/http.rb +108 -0
- data/lib/connectors/mcp/interaction.rb +82 -0
- data/lib/connectors/mcp/pending_transaction.rb +26 -0
- data/lib/connectors/mcp/protocol/2026-07-28.json +3963 -0
- data/lib/connectors/mcp/protocol/LICENSE +216 -0
- data/lib/connectors/mcp/protocol/README.md +8 -0
- data/lib/connectors/mcp/protocol_schema.rb +30 -0
- data/lib/connectors/mcp/schema.rb +45 -0
- data/lib/connectors/mcp/settings.rb +27 -0
- data/lib/connectors/mcp/token_endpoint.rb +48 -0
- data/lib/connectors/mcp/transport.rb +105 -0
- data/lib/connectors/mcp.rb +69 -0
- data/lib/connectors/middleware/authenticate_generic.rb +79 -0
- data/lib/connectors/middleware/auto_refresh.rb +71 -0
- data/lib/connectors/middleware/error_normalization.rb +45 -0
- data/lib/connectors/middleware/grant_status.rb +19 -0
- data/lib/connectors/middleware/pre_authentication.rb +54 -0
- data/lib/connectors/middleware/rate_limit.rb +40 -0
- data/lib/connectors/oauth/authorize_url.rb +78 -0
- data/lib/connectors/oauth/client_authentication.rb +24 -0
- data/lib/connectors/oauth/client_credentials.rb +43 -0
- data/lib/connectors/oauth/grant_writer.rb +73 -0
- data/lib/connectors/oauth/pkce.rb +32 -0
- data/lib/connectors/oauth/revoke.rb +72 -0
- data/lib/connectors/oauth/state.rb +41 -0
- data/lib/connectors/oauth/token_exchange.rb +69 -0
- data/lib/connectors/oauth/token_response.rb +40 -0
- data/lib/connectors/oauth.rb +4 -0
- data/lib/connectors/oauth1.rb +151 -0
- data/lib/connectors/permission_check.rb +33 -0
- data/lib/connectors/poll_runner.rb +50 -0
- data/lib/connectors/pre_authentication_helpers.rb +76 -0
- data/lib/connectors/registry.rb +38 -0
- data/lib/connectors/version.rb +3 -0
- data/lib/connectors/webhook_context.rb +62 -0
- data/lib/connectors/webhook_lifecycle.rb +69 -0
- data/lib/connectors/webhook_methods.rb +48 -0
- data/lib/connectors/webhooks/verifier.rb +31 -0
- data/lib/connectors/webhooks.rb +5 -0
- data/lib/connectors.rb +50 -0
- data/lib/tasks/connectors_mcp.rake +9 -0
- data/lib/tasks/connectors_tasks.rake +4 -0
- data/openapi.yaml +1099 -0
- metadata +263 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
module MCP
|
|
3
|
+
class Client
|
|
4
|
+
def initialize(grant:, actor:, principals: nil, cancellation: nil)
|
|
5
|
+
@access = Access.new(grant: grant, actor: actor, principals: principals)
|
|
6
|
+
@cancellation = cancellation
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def tools
|
|
10
|
+
@access.require!
|
|
11
|
+
fingerprint = @access.fingerprint
|
|
12
|
+
result = []
|
|
13
|
+
cursors = Set.new
|
|
14
|
+
cursor = nil
|
|
15
|
+
Connectors.configuration.mcp.max_pages.times do
|
|
16
|
+
@access.require!
|
|
17
|
+
unchanged!(fingerprint)
|
|
18
|
+
page = request("tools/list", cursor.nil? ? {} : { "cursor" => cursor })
|
|
19
|
+
raise ProtocolError, "Invalid tools page" unless page.fetch("resultType", "complete") == "complete" && page["tools"].is_a?(Array)
|
|
20
|
+
page["tools"].each do |tool|
|
|
21
|
+
raise ProtocolError, "Invalid tool descriptor" unless tool.is_a?(Hash) && tool["name"].is_a?(String) && !tool["name"].empty?
|
|
22
|
+
scan = ::MCP::Client::McpParamHeaders.scan(tool["inputSchema"])
|
|
23
|
+
next unless scan[:valid] && scan[:declarations].none? { |d| d[:type] == "number" }
|
|
24
|
+
ProtocolSchema.validate!("Tool", tool)
|
|
25
|
+
Schema.new(tool["inputSchema"])
|
|
26
|
+
Schema.new(tool["outputSchema"]) if tool.key?("outputSchema")
|
|
27
|
+
if (previous = result.find { |t| t["name"] == tool["name"] })
|
|
28
|
+
raise ProtocolError, "Conflicting tool definitions" unless previous == tool
|
|
29
|
+
else
|
|
30
|
+
result << tool
|
|
31
|
+
end
|
|
32
|
+
raise ProtocolError, "Tool catalog exceeds limit" if result.size > Connectors.configuration.mcp.max_tools
|
|
33
|
+
end
|
|
34
|
+
cursor = page["nextCursor"]
|
|
35
|
+
return result if cursor.nil?
|
|
36
|
+
raise ProtocolError, "Invalid or repeated pagination cursor" unless cursor.is_a?(String) && cursors.add?(cursor)
|
|
37
|
+
end
|
|
38
|
+
raise ProtocolError, "Tool pagination exceeds limit"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call_tool(name:, arguments:)
|
|
42
|
+
@access.require!(:editor)
|
|
43
|
+
fingerprint = @access.fingerprint
|
|
44
|
+
raise ValidationError, "Tool arguments must be an object" unless arguments.is_a?(Hash)
|
|
45
|
+
tool = tools.find { |t| t["name"] == name } or raise ValidationError, "Unknown MCP tool"
|
|
46
|
+
Schema.new(tool["inputSchema"]).validate!(arguments)
|
|
47
|
+
params = { "name" => name, "arguments" => arguments }
|
|
48
|
+
invoke(tool, params, fingerprint: fingerprint)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def subscribe
|
|
52
|
+
raise ArgumentError, "A notification consumer is required" unless block_given?
|
|
53
|
+
@access.require!
|
|
54
|
+
fingerprint = @access.fingerprint
|
|
55
|
+
transport.request("subscriptions/listen", { "notifications" => { "toolsListChanged" => true } }, subscription: true, cancellation: @cancellation) do |message|
|
|
56
|
+
@access.require!
|
|
57
|
+
unchanged!(fingerprint)
|
|
58
|
+
yield message
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def resume(interaction_id:, responses:)
|
|
63
|
+
Interaction.new(@access).resume(id: interaction_id, responses: responses) do |tool, params, rounds, fingerprint|
|
|
64
|
+
invoke(tool, params, rounds: rounds, fingerprint: fingerprint)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def invoke(tool, params, rounds: 0, fingerprint:)
|
|
71
|
+
@access.require!(:editor)
|
|
72
|
+
unchanged!(fingerprint)
|
|
73
|
+
headers = ::MCP::Client::McpParamHeaders.build(::MCP::Client::McpParamHeaders.scan(tool["inputSchema"])[:declarations], params["arguments"])
|
|
74
|
+
result = request("tools/call", params, parameter_headers: headers, fingerprint: fingerprint)
|
|
75
|
+
if result["resultType"] == "input_required"
|
|
76
|
+
return Interaction.new(@access).pause(tool: tool, params: params, result: result, rounds: rounds, fingerprint: fingerprint)
|
|
77
|
+
end
|
|
78
|
+
ProtocolSchema.validate!("CallToolResult", result)
|
|
79
|
+
if tool.key?("outputSchema") && !result["isError"]
|
|
80
|
+
raise ValidationError, "Missing structured tool result" unless result.key?("structuredContent")
|
|
81
|
+
Schema.new(tool["outputSchema"]).validate!(result["structuredContent"])
|
|
82
|
+
end
|
|
83
|
+
result
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def transport
|
|
87
|
+
@token = nil
|
|
88
|
+
data = @access.configuration
|
|
89
|
+
fingerprint = @access.fingerprint
|
|
90
|
+
headers = data.fetch("headers", {}).dup
|
|
91
|
+
case data.fetch("auth_mode", "none")
|
|
92
|
+
when "bearer" then headers["Authorization"] = "Bearer #{data.fetch('bearer_token')}"
|
|
93
|
+
when "oauth"
|
|
94
|
+
begin
|
|
95
|
+
@token = authorization.access_token
|
|
96
|
+
headers["Authorization"] = "Bearer #{@token}"
|
|
97
|
+
rescue AuthorizationRequired
|
|
98
|
+
# Probe without credentials to obtain the authoritative resource/scope challenge.
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
unchanged!(fingerprint)
|
|
102
|
+
modes = Connectors.configuration.mcp.elicitation_modes
|
|
103
|
+
capabilities = modes.empty? ? {} : { "elicitation" => modes.to_h { |mode| [ mode, {} ] } }
|
|
104
|
+
Transport.new(url: data.fetch("server_url"), headers: headers, capabilities: capabilities)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def authorization
|
|
108
|
+
Authorization.new(grant: @access.grant, actor: @access.actor, principals: @access.principals)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def request(method, params, fingerprint: nil, **options)
|
|
112
|
+
role = method == "tools/call" ? :editor : :viewer
|
|
113
|
+
@access.require!(role)
|
|
114
|
+
fingerprint ||= @access.fingerprint
|
|
115
|
+
unchanged!(fingerprint)
|
|
116
|
+
begin
|
|
117
|
+
result = transport.request(method, params, cancellation: @cancellation, **options)
|
|
118
|
+
rescue AuthorizationRequired => error
|
|
119
|
+
@access.require!(role)
|
|
120
|
+
unchanged!(fingerprint)
|
|
121
|
+
if error.challenge["error"] == "insufficient_scope" || !@token
|
|
122
|
+
raise AuthorizationRequired.new(error.challenge, fingerprint: fingerprint)
|
|
123
|
+
end
|
|
124
|
+
authorization.refresh(expected_token: @token)
|
|
125
|
+
result = transport.request(method, params, cancellation: @cancellation, **options)
|
|
126
|
+
end
|
|
127
|
+
@access.require!(role)
|
|
128
|
+
unchanged!(fingerprint)
|
|
129
|
+
result
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def unchanged!(fingerprint)
|
|
133
|
+
raise AccessDenied, "MCP connection changed during the request" unless fingerprint == @access.fingerprint
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
module MCP
|
|
3
|
+
class ConnectionConfig
|
|
4
|
+
FIELDS = %w[server_url auth_mode bearer_token headers client_information authorization_server].freeze
|
|
5
|
+
PUBLIC_FIELDS = %w[server_url auth_mode authorization_server].freeze
|
|
6
|
+
RESERVED_HEADERS = /\A(?:host|content-.*|accept|connection|transfer-encoding|cookie|proxy-.*|mcp-.*)\z/i
|
|
7
|
+
|
|
8
|
+
def self.resolve(data, connector:, **options)
|
|
9
|
+
fixed = connector.mcp_config
|
|
10
|
+
raise ValidationError, "Connector does not support MCP" unless fixed
|
|
11
|
+
fixed.each do |key, value|
|
|
12
|
+
raise ValidationError, "#{key} is fixed for this MCP provider" if data.key?(key) && data[key] != value
|
|
13
|
+
end
|
|
14
|
+
if fixed["auth_mode"] == "oauth" && (data.keys & %w[bearer_token headers]).any?
|
|
15
|
+
raise ValidationError, "This MCP provider requires OAuth authentication"
|
|
16
|
+
end
|
|
17
|
+
validate!(fixed.merge(data), **options)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.validate!(data, internal: false, require_secrets: true)
|
|
21
|
+
allowed = internal ? FIELDS + [ "mcp_oauth" ] : FIELDS
|
|
22
|
+
raise ValidationError, "Unsupported MCP credential fields" if (data.keys - allowed).any?
|
|
23
|
+
HTTP.new.validate_url!(data.fetch("server_url", ""))
|
|
24
|
+
mode = data.fetch("auth_mode", "none")
|
|
25
|
+
raise ValidationError, "Unsupported MCP authentication mode" unless %w[none bearer headers oauth].include?(mode)
|
|
26
|
+
if mode == "bearer" && (require_secrets || data.key?("bearer_token")) && (!data["bearer_token"].is_a?(String) || data["bearer_token"].blank? || data["bearer_token"].match?(/[\r\n]/))
|
|
27
|
+
raise ValidationError, "A valid bearer token is required"
|
|
28
|
+
end
|
|
29
|
+
headers = data.fetch("headers", {})
|
|
30
|
+
raise ValidationError, "Headers must be an object" unless headers.is_a?(Hash)
|
|
31
|
+
headers.each do |key, value|
|
|
32
|
+
unless key.is_a?(String) && key.match?(::MCP::Client::McpParamHeaders::RFC9110_TOKEN) && !key.match?(RESERVED_HEADERS) && value.is_a?(String) && !value.match?(/[\r\n\x00]/)
|
|
33
|
+
raise ValidationError, "Invalid or reserved MCP header"
|
|
34
|
+
end
|
|
35
|
+
raise ValidationError, "Authorization header requires headers auth mode" if key.casecmp?("authorization") && mode != "headers"
|
|
36
|
+
end
|
|
37
|
+
if data["client_information"] && !data["client_information"].is_a?(Hash)
|
|
38
|
+
raise ValidationError, "Client information must be an object"
|
|
39
|
+
end
|
|
40
|
+
data
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.public_data(data)
|
|
44
|
+
data.slice(*PUBLIC_FIELDS)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "ipaddr"
|
|
3
|
+
require "socket"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Connectors
|
|
7
|
+
module MCP
|
|
8
|
+
# Pins the validated address while retaining the hostname for TLS/SNI.
|
|
9
|
+
# Redirects and environment proxies are deliberately not followed.
|
|
10
|
+
class HTTP
|
|
11
|
+
Response = Data.define(:status, :headers, :body)
|
|
12
|
+
BLOCKED = %w[0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16 198.18.0.0/15 224.0.0.0/4 240.0.0.0/4 ::/128 ::1/128 fc00::/7 fe80::/10 ff00::/8].map { |s| IPAddr.new(s) }.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(settings: Connectors.configuration.mcp)
|
|
15
|
+
@settings = settings
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def validate_url!(url)
|
|
19
|
+
uri = URI.parse(url.to_s)
|
|
20
|
+
raise ConfigurationRequired, "Invalid MCP destination" unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.fragment
|
|
21
|
+
loopback = uri.hostname == "localhost" || IPAddr.new(uri.hostname).loopback? rescue false
|
|
22
|
+
unless uri.scheme == "https" || (@settings.allow_loopback_http && loopback && uri.scheme == "http")
|
|
23
|
+
raise ConfigurationRequired, "MCP destinations require HTTPS"
|
|
24
|
+
end
|
|
25
|
+
uri
|
|
26
|
+
rescue URI::InvalidURIError
|
|
27
|
+
raise ConfigurationRequired, "Invalid MCP destination"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(url:, method: :get, headers: {}, body: nil, stream: false, cancellation: nil, &consumer)
|
|
31
|
+
cancellation_key = nil
|
|
32
|
+
cancellation&.check!
|
|
33
|
+
timeout = stream ? @settings.stream_timeout : @settings.request_timeout
|
|
34
|
+
raise ConfigurationRequired, "Request limits must be positive" unless timeout.to_f.positive? && @settings.max_bytes.to_i.positive?
|
|
35
|
+
raise ValidationError, "MCP request exceeds size limit" if body && body.bytesize > @settings.max_bytes
|
|
36
|
+
Timeout.timeout(timeout, TransportError, "MCP request deadline exceeded") do
|
|
37
|
+
uri = validate_url!(url)
|
|
38
|
+
addresses = Addrinfo.getaddrinfo(uri.hostname, uri.port, nil, :STREAM).map(&:ip_address).uniq
|
|
39
|
+
raise TransportError, "MCP destination could not be resolved" if addresses.empty?
|
|
40
|
+
permitted = @settings.private_hosts.include?(uri.hostname) || (@settings.allow_loopback_http && addresses.all? { |ip| IPAddr.new(ip).loopback? })
|
|
41
|
+
unless permitted || addresses.none? { |ip| blocked?(ip) }
|
|
42
|
+
raise ConfigurationRequired, "MCP destination resolves to a restricted network"
|
|
43
|
+
end
|
|
44
|
+
connection = Net::HTTP.new(uri.hostname, uri.port, nil)
|
|
45
|
+
connection.ipaddr = addresses.first
|
|
46
|
+
connection.use_ssl = uri.scheme == "https"
|
|
47
|
+
connection.open_timeout = @settings.open_timeout
|
|
48
|
+
connection.read_timeout = timeout
|
|
49
|
+
connection.write_timeout = timeout
|
|
50
|
+
connection.max_retries = 0
|
|
51
|
+
request = Net::HTTPGenericRequest.new(method.to_s.upcase, !body.nil?, true, uri.request_uri, headers)
|
|
52
|
+
request.body = body if body
|
|
53
|
+
connection.start do |http|
|
|
54
|
+
cancellation_key = cancellation&.attach do
|
|
55
|
+
begin
|
|
56
|
+
http.finish if http.started?
|
|
57
|
+
rescue IOError
|
|
58
|
+
# The response may have closed the connection concurrently.
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
http.request(request) do |response|
|
|
62
|
+
status = response.code.to_i
|
|
63
|
+
response_headers = response.each_header.to_h
|
|
64
|
+
content = +""
|
|
65
|
+
size = 0
|
|
66
|
+
response.read_body do |chunk|
|
|
67
|
+
cancellation&.check!
|
|
68
|
+
size += chunk.bytesize
|
|
69
|
+
raise TransportError, "MCP response exceeds size limit" if size > @settings.max_bytes
|
|
70
|
+
if consumer && status.between?(200, 299)
|
|
71
|
+
consumer.call(chunk, response_headers)
|
|
72
|
+
else
|
|
73
|
+
content << chunk
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
result = Response.new(status: status, headers: response_headers, body: content)
|
|
77
|
+
raise HTTPError.new(status: status, headers: response_headers, body: content) unless status.between?(200, 299)
|
|
78
|
+
return result
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
rescue IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError
|
|
83
|
+
cancellation&.check!
|
|
84
|
+
raise TransportError, "MCP network request failed"
|
|
85
|
+
ensure
|
|
86
|
+
cancellation&.detach(cancellation_key) if cancellation_key
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def json(**options)
|
|
90
|
+
response = call(**options)
|
|
91
|
+
raise ProtocolError, "Expected JSON metadata" unless response.headers["content-type"].to_s.split(";").first == "application/json"
|
|
92
|
+
parsed = JSON.parse(response.body)
|
|
93
|
+
raise ProtocolError, "Expected JSON object" unless parsed.is_a?(Hash)
|
|
94
|
+
parsed
|
|
95
|
+
rescue JSON::ParserError
|
|
96
|
+
raise ProtocolError, "Invalid JSON metadata"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def blocked?(address)
|
|
102
|
+
ip = IPAddr.new(address)
|
|
103
|
+
ip = ip.native if ip.ipv4_mapped?
|
|
104
|
+
BLOCKED.any? { |range| range.include?(ip) }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
module MCP
|
|
3
|
+
class Interaction
|
|
4
|
+
def initialize(access)
|
|
5
|
+
@access = access
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def pause(tool:, params:, result:, rounds:, fingerprint:)
|
|
9
|
+
ProtocolSchema.validate!("InputRequiredResult", result)
|
|
10
|
+
raise ProtocolError, "MCP interaction round limit exceeded" if rounds >= Connectors.configuration.mcp.max_rounds
|
|
11
|
+
requests = result.fetch("inputRequests", {})
|
|
12
|
+
raise ProtocolError, "Invalid MCP input requests" unless requests.is_a?(Hash) && (requests.any? || result["requestState"].is_a?(String))
|
|
13
|
+
if result.key?("requestState") && !result["requestState"].is_a?(String)
|
|
14
|
+
raise ProtocolError, "Invalid MCP request state"
|
|
15
|
+
end
|
|
16
|
+
requests.each_value { |request| validate_request!(request) }
|
|
17
|
+
@access.require!(:editor)
|
|
18
|
+
raise AccessDenied, "MCP connection changed" unless fingerprint == @access.fingerprint
|
|
19
|
+
record = Connectors::McpInteraction.create!(grant: @access.grant, actor_key: @access.actor_key, fingerprint: fingerprint,
|
|
20
|
+
expires_at: Connectors.configuration.mcp.transaction_ttl.from_now,
|
|
21
|
+
payload: { "tool" => tool, "params" => params, "result" => result, "rounds" => rounds })
|
|
22
|
+
# The opaque server state stays encrypted at rest, never in a caller-controlled resume payload.
|
|
23
|
+
result.except("requestState").merge("interaction_id" => record.id)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def resume(id:, responses:)
|
|
27
|
+
@access.require!(:editor)
|
|
28
|
+
record = Connectors::McpInteraction.find_by(id: id, grant_id: @access.grant.id)
|
|
29
|
+
raise AccessDenied, "Unknown MCP interaction" unless record
|
|
30
|
+
payload = record.claim!(@access) do |pending|
|
|
31
|
+
validate_responses!(pending.fetch("result").fetch("inputRequests", {}), responses)
|
|
32
|
+
end
|
|
33
|
+
claimed = true
|
|
34
|
+
params = payload.fetch("params").except("inputResponses", "requestState")
|
|
35
|
+
params["inputResponses"] = responses unless responses.empty?
|
|
36
|
+
params["requestState"] = payload["result"]["requestState"] if payload["result"].key?("requestState")
|
|
37
|
+
yield payload.fetch("tool"), params, payload.fetch("rounds") + 1, record.fingerprint
|
|
38
|
+
ensure
|
|
39
|
+
record.update!(status: "consumed", payload: { "cleared" => true }) if claimed
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def validate_request!(request)
|
|
45
|
+
ProtocolSchema.validate!("ElicitRequest", request)
|
|
46
|
+
unless request.is_a?(Hash) && request["method"] == "elicitation/create" && request["params"].is_a?(Hash)
|
|
47
|
+
raise ProtocolError, "Unsupported MCP input request"
|
|
48
|
+
end
|
|
49
|
+
params = request["params"]
|
|
50
|
+
mode = params.fetch("mode", "form")
|
|
51
|
+
raise ProtocolError, "Unadvertised elicitation mode" unless Connectors.configuration.mcp.elicitation_modes.include?(mode)
|
|
52
|
+
raise ProtocolError, "Invalid elicitation message" unless params["message"].is_a?(String)
|
|
53
|
+
case mode
|
|
54
|
+
when "url" then HTTP.new.validate_url!(params["url"])
|
|
55
|
+
when "form" then Schema.new(params["requestedSchema"])
|
|
56
|
+
else raise ProtocolError, "Unsupported elicitation mode"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_responses!(requests, responses)
|
|
61
|
+
unless responses.is_a?(Hash) && responses.keys.sort == requests.keys.sort
|
|
62
|
+
raise ValidationError, "Input responses must match the pending requests"
|
|
63
|
+
end
|
|
64
|
+
requests.each do |key, request|
|
|
65
|
+
response = responses[key]
|
|
66
|
+
ProtocolSchema.validate!("ElicitResult", response)
|
|
67
|
+
unless response.is_a?(Hash) && %w[accept decline cancel].include?(response["action"])
|
|
68
|
+
raise ValidationError, "Invalid elicitation action"
|
|
69
|
+
end
|
|
70
|
+
mode = request["params"].fetch("mode", "form")
|
|
71
|
+
if mode == "form" && response["action"] == "accept"
|
|
72
|
+
Schema.new(request["params"]["requestedSchema"]).validate!(response["content"])
|
|
73
|
+
elsif response.key?("content")
|
|
74
|
+
raise ValidationError, "This elicitation response must omit content"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
rescue ProtocolError
|
|
78
|
+
raise ValidationError, "Invalid elicitation response"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
module MCP
|
|
3
|
+
module PendingTransaction
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
included do
|
|
6
|
+
belongs_to :grant, class_name: "Connectors::Grant"
|
|
7
|
+
serialize :payload, coder: JSON, type: Hash
|
|
8
|
+
encrypts :payload
|
|
9
|
+
scope :expired, -> { where(expires_at: ..Time.current) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def claim!(access)
|
|
13
|
+
with_lock do
|
|
14
|
+
access.require!(self.class::REQUIRED_ROLE)
|
|
15
|
+
unless status == "pending" && expires_at > Time.current && grant_id == access.grant.id && actor_key == access.actor_key && fingerprint == access.fingerprint
|
|
16
|
+
raise AccessDenied, "MCP transaction is expired, changed, or already used"
|
|
17
|
+
end
|
|
18
|
+
data = payload.deep_dup
|
|
19
|
+
yield data if block_given?
|
|
20
|
+
update!(status: "claimed")
|
|
21
|
+
data
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|