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.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +35 -0
  3. data/CONNECTORS_FRAMEWORK.md +799 -0
  4. data/CONTRIBUTING.md +60 -0
  5. data/MCP_CLIENT.md +168 -0
  6. data/MIT-LICENSE +20 -0
  7. data/README.md +146 -0
  8. data/app/connectors/clickup/connector.rb +13 -0
  9. data/app/connectors/gmail/api.rb +114 -0
  10. data/app/connectors/gmail/connector.rb +921 -0
  11. data/app/connectors/gmail/mime_builder.rb +261 -0
  12. data/app/connectors/gmail/mime_parser.rb +106 -0
  13. data/app/connectors/gmail/polling.rb +154 -0
  14. data/app/connectors/remote_mcp/connector.rb +18 -0
  15. data/app/connectors/resend/connector.rb +218 -0
  16. data/app/controllers/concerns/connectors/grant_access.rb +43 -0
  17. data/app/controllers/connectors/actions_controller.rb +66 -0
  18. data/app/controllers/connectors/application_controller.rb +5 -0
  19. data/app/controllers/connectors/credentials_controller.rb +245 -0
  20. data/app/controllers/connectors/grants_controller.rb +123 -0
  21. data/app/controllers/connectors/mcp_controller.rb +88 -0
  22. data/app/controllers/connectors/oauth_controller.rb +134 -0
  23. data/app/controllers/connectors/types_controller.rb +144 -0
  24. data/app/controllers/connectors/webhooks_controller.rb +105 -0
  25. data/app/jobs/connectors/application_job.rb +4 -0
  26. data/app/jobs/connectors/deliver_webhook_job.rb +27 -0
  27. data/app/jobs/connectors/poll_job.rb +49 -0
  28. data/app/models/connectors/application_record.rb +5 -0
  29. data/app/models/connectors/credential_share.rb +31 -0
  30. data/app/models/connectors/grant.rb +103 -0
  31. data/app/models/connectors/mcp_authorization.rb +6 -0
  32. data/app/models/connectors/mcp_interaction.rb +6 -0
  33. data/app/models/connectors/poll_state.rb +17 -0
  34. data/app/models/connectors/webhook_event.rb +19 -0
  35. data/config/routes.rb +68 -0
  36. data/db/migrate/20260518210324_create_connectors_grants.rb +49 -0
  37. data/db/migrate/20260518214609_create_connectors_webhook_events.rb +35 -0
  38. data/db/migrate/20260521140000_create_connectors_credential_shares.rb +26 -0
  39. data/db/migrate/20260922120000_create_connectors_poll_states.rb +12 -0
  40. data/db/migrate/20260922130000_create_connectors_mcp_transactions.rb +18 -0
  41. data/docs/adding-connectors.md +92 -0
  42. data/docs/architecture.md +71 -0
  43. data/docs/releasing.md +60 -0
  44. data/lib/connectors/action.rb +90 -0
  45. data/lib/connectors/action_builder.rb +125 -0
  46. data/lib/connectors/action_runner.rb +99 -0
  47. data/lib/connectors/auth/scheme/api_key.rb +51 -0
  48. data/lib/connectors/auth/scheme/oauth2.rb +23 -0
  49. data/lib/connectors/auth/scheme.rb +35 -0
  50. data/lib/connectors/auth.rb +4 -0
  51. data/lib/connectors/auth_injection.rb +65 -0
  52. data/lib/connectors/client_builder.rb +87 -0
  53. data/lib/connectors/configuration.rb +138 -0
  54. data/lib/connectors/connector.rb +565 -0
  55. data/lib/connectors/credential_schema.rb +219 -0
  56. data/lib/connectors/credential_tester.rb +85 -0
  57. data/lib/connectors/credential_type_registry.rb +162 -0
  58. data/lib/connectors/credential_types/http_auth.rb +171 -0
  59. data/lib/connectors/engine.rb +123 -0
  60. data/lib/connectors/errors.rb +88 -0
  61. data/lib/connectors/grant_policy.rb +13 -0
  62. data/lib/connectors/mcp/access.rb +50 -0
  63. data/lib/connectors/mcp/authorization.rb +177 -0
  64. data/lib/connectors/mcp/authorization_context.rb +25 -0
  65. data/lib/connectors/mcp/authorization_discovery.rb +42 -0
  66. data/lib/connectors/mcp/cancellation.rb +36 -0
  67. data/lib/connectors/mcp/client.rb +137 -0
  68. data/lib/connectors/mcp/connection_config.rb +48 -0
  69. data/lib/connectors/mcp/http.rb +108 -0
  70. data/lib/connectors/mcp/interaction.rb +82 -0
  71. data/lib/connectors/mcp/pending_transaction.rb +26 -0
  72. data/lib/connectors/mcp/protocol/2026-07-28.json +3963 -0
  73. data/lib/connectors/mcp/protocol/LICENSE +216 -0
  74. data/lib/connectors/mcp/protocol/README.md +8 -0
  75. data/lib/connectors/mcp/protocol_schema.rb +30 -0
  76. data/lib/connectors/mcp/schema.rb +45 -0
  77. data/lib/connectors/mcp/settings.rb +27 -0
  78. data/lib/connectors/mcp/token_endpoint.rb +48 -0
  79. data/lib/connectors/mcp/transport.rb +105 -0
  80. data/lib/connectors/mcp.rb +69 -0
  81. data/lib/connectors/middleware/authenticate_generic.rb +79 -0
  82. data/lib/connectors/middleware/auto_refresh.rb +71 -0
  83. data/lib/connectors/middleware/error_normalization.rb +45 -0
  84. data/lib/connectors/middleware/grant_status.rb +19 -0
  85. data/lib/connectors/middleware/pre_authentication.rb +54 -0
  86. data/lib/connectors/middleware/rate_limit.rb +40 -0
  87. data/lib/connectors/oauth/authorize_url.rb +78 -0
  88. data/lib/connectors/oauth/client_authentication.rb +24 -0
  89. data/lib/connectors/oauth/client_credentials.rb +43 -0
  90. data/lib/connectors/oauth/grant_writer.rb +73 -0
  91. data/lib/connectors/oauth/pkce.rb +32 -0
  92. data/lib/connectors/oauth/revoke.rb +72 -0
  93. data/lib/connectors/oauth/state.rb +41 -0
  94. data/lib/connectors/oauth/token_exchange.rb +69 -0
  95. data/lib/connectors/oauth/token_response.rb +40 -0
  96. data/lib/connectors/oauth.rb +4 -0
  97. data/lib/connectors/oauth1.rb +151 -0
  98. data/lib/connectors/permission_check.rb +33 -0
  99. data/lib/connectors/poll_runner.rb +50 -0
  100. data/lib/connectors/pre_authentication_helpers.rb +76 -0
  101. data/lib/connectors/registry.rb +38 -0
  102. data/lib/connectors/version.rb +3 -0
  103. data/lib/connectors/webhook_context.rb +62 -0
  104. data/lib/connectors/webhook_lifecycle.rb +69 -0
  105. data/lib/connectors/webhook_methods.rb +48 -0
  106. data/lib/connectors/webhooks/verifier.rb +31 -0
  107. data/lib/connectors/webhooks.rb +5 -0
  108. data/lib/connectors.rb +50 -0
  109. data/lib/tasks/connectors_mcp.rake +9 -0
  110. data/lib/tasks/connectors_tasks.rake +4 -0
  111. data/openapi.yaml +1099 -0
  112. 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