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,123 @@
1
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
2
+ inflect.acronym "OAuth"
3
+ end
4
+
5
+ module Connectors
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace Connectors
8
+
9
+ config.generators.api_only = true
10
+
11
+ initializer :connectors_mcp_filter_secrets do |app|
12
+ app.config.filter_parameters += %i[bearer_token mcp_oauth client_information code state authorization_context arguments responses headers]
13
+ end
14
+
15
+ # Rails's built-in append_migrations sometimes misses path-based engine
16
+ # sources, so wire our migration directory in explicitly. Without this,
17
+ # `bin/rails db:migrate` in a host app silently skips the engine's
18
+ # tables.
19
+ initializer :connectors_append_engine_migrations do |app|
20
+ # Rails adds this engine's paths itself when running its Rake tasks.
21
+ # Adding them here too makes every migration appear twice.
22
+ next if defined?(ENGINE_ROOT) && File.expand_path(ENGINE_ROOT) == root.to_s
23
+
24
+ paths = app.config.paths["db/migrate"]
25
+ own = ::Connectors::Engine.root.join("db/migrate").to_s
26
+ paths << own unless paths.to_a.include?(own)
27
+ end
28
+
29
+ # Register canonical base credential schemas so connectors can declare
30
+ # `extends :oauth2` (and inherit the OAuth2 form fields). The registry
31
+ # is process-local; reset between tests via the spec helper.
32
+ initializer :connectors_register_credential_types do
33
+ reg = ::Connectors::CredentialTypeRegistry
34
+ reg.register(:oauth2, reg::OAUTH2)
35
+ # Phase 4 — n8n's `OAuth1Api` base type
36
+ # (packages/nodes-base/credentials/OAuth1Api.credentials.ts:1-72).
37
+ reg.register(:oauth1_api, reg::OAUTH1)
38
+ # Phase 2 — n8n's generic HTTP auth credential types
39
+ # (packages/nodes-base/credentials/Http*Auth.credentials.ts).
40
+ reg.register(:http_basic_auth, ::Connectors::CredentialTypes::HTTP_BASIC_AUTH)
41
+ reg.register(:http_bearer_auth, ::Connectors::CredentialTypes::HTTP_BEARER_AUTH)
42
+ reg.register(:http_header_auth, ::Connectors::CredentialTypes::HTTP_HEADER_AUTH)
43
+ reg.register(:http_query_auth, ::Connectors::CredentialTypes::HTTP_QUERY_AUTH)
44
+ reg.register(:http_digest_auth, ::Connectors::CredentialTypes::HTTP_DIGEST_AUTH)
45
+ reg.register(:http_custom_auth, ::Connectors::CredentialTypes::HTTP_CUSTOM_AUTH)
46
+ end
47
+
48
+ # Connector classes register themselves with Connectors::Registry as a
49
+ # side effect of being loaded (the `connector key:, ...` DSL call in
50
+ # the class body). Zeitwerk autoloads on-demand though — so without
51
+ # something pulling each class in, `GET /connectors/types` would return
52
+ # an empty list in dev. Force-load every `app/connectors/<key>/connector.rb`
53
+ # on boot so the registry is populated before the first request.
54
+ #
55
+ # Walks every Engine root (including the host's main app) and globs each
56
+ # one's `app/connectors/` — the union covers shipped connectors here in
57
+ # the engine, plus host-app overrides under flow-api's own
58
+ # `app/connectors/` (if/when the host decides to ship its own).
59
+ config.to_prepare do
60
+ ::Rails::Engine.subclasses.map(&:instance).push(::Rails.application).uniq.each do |engine|
61
+ dir = engine.root.join("app/connectors")
62
+ next unless dir.directory?
63
+ dir.glob("*/connector.rb").each { |f| require_dependency f.to_s }
64
+ end
65
+ end
66
+
67
+ # Returns the path the engine is actually mounted at in the host app —
68
+ # `/connectors`, `/api/v1/connectors`, anything the host chose. Walks
69
+ # the host's compiled route table to find this engine's mount; falls
70
+ # back to `/connectors` only when the engine isn't mounted (isolated
71
+ # specs that boot without a full app). Cached after first lookup; the
72
+ # `to_prepare` block above ensures it's still warm after code reloads.
73
+ #
74
+ # Used by every URL builder in the engine that needs to round-trip an
75
+ # absolute URL back to the engine's own routes (OAuth callback,
76
+ # default webhook URL, types-endpoint `redirect_uri` field) so that
77
+ # `mount Engine => "/api/v1/connectors"` Just Works without any host
78
+ # configuration beyond the routes line itself.
79
+ def self.mount_path
80
+ return @mount_path if defined?(@mount_path) && @mount_path
81
+ @mount_path = lookup_mount_path
82
+ end
83
+
84
+ # Reset the cache. Call after re-mounting in a spec; the host never
85
+ # needs this in normal operation.
86
+ def self.reset_mount_path!
87
+ remove_instance_variable(:@mount_path) if defined?(@mount_path)
88
+ end
89
+
90
+ def self.lookup_mount_path
91
+ return "/connectors" unless defined?(::Rails) && ::Rails.application&.routes
92
+ ::Rails.application.routes.routes.each do |route|
93
+ next unless route_targets_engine?(route)
94
+ path = route.path.spec.to_s.dup
95
+ path.chomp!("(.:format)")
96
+ path.chomp!("/")
97
+ return path.empty? ? "/" : path
98
+ end
99
+ "/connectors"
100
+ end
101
+
102
+ # Mounted engines sit at varying depths in `route.app`'s wrapper chain.
103
+ # In a bare host (`mount Connectors::Engine => "/connectors"`) the
104
+ # chain is `Constraints → Connectors::Engine`. Inside a namespace
105
+ # (`namespace :api { scope "v1" { mount Connectors::Engine => "/connectors" } }`)
106
+ # the chain becomes `Constraints → Connectors::Engine → LazyRouteSet`,
107
+ # so unwrapping to the terminal misses the engine entirely. Walk every
108
+ # layer instead and stop the first time we encounter `self`.
109
+ def self.route_targets_engine?(route)
110
+ target = route.app
111
+ visited = {}
112
+ while target && !visited[target.object_id]
113
+ return true if target == self
114
+ visited[target.object_id] = true
115
+ return false unless target.respond_to?(:app)
116
+ nxt = target.app
117
+ return false if nxt == target
118
+ target = nxt
119
+ end
120
+ false
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,88 @@
1
+ module Connectors
2
+ class Error < StandardError; end
3
+
4
+ class UnknownConnector < Error
5
+ def initialize(key, known: [])
6
+ super("no connector registered for #{key.inspect}. Known keys: #{known.inspect}")
7
+ end
8
+ end
9
+
10
+ class UnknownAuthScheme < Error
11
+ def initialize(name, known: [])
12
+ super("no auth scheme registered for #{name.inspect}. Known schemes: #{known.inspect}")
13
+ end
14
+ end
15
+
16
+ class MissingCredentialFields < Error
17
+ def initialize(fields)
18
+ super("missing required credential fields: #{Array(fields).join(', ')}")
19
+ end
20
+ end
21
+
22
+ class ApiError < Error
23
+ attr_reader :status, :body
24
+
25
+ def initialize(message, status: nil, body: nil)
26
+ super(message)
27
+ @status = status
28
+ @body = body
29
+ end
30
+ end
31
+
32
+ class AuthenticationFailed < ApiError; end
33
+ class Forbidden < ApiError; end
34
+
35
+ # A credential was used with a node that's not on its `supported_nodes`
36
+ # allowlist, OR the credential is marked generic_auth-only and the caller
37
+ # is a non-HTTP-Request node. Mirrors the visibility gate n8n applies via
38
+ # the credential's `supportedNodes` field (interfaces.ts:381) and
39
+ # `genericAuth` flag (interfaces.ts:379).
40
+ class CredentialNotPermitted < Error
41
+ def initialize(connector_key, node_type, reason: nil)
42
+ msg = "credential #{connector_key.inspect} is not permitted for node #{node_type.inspect}"
43
+ msg += " (#{reason})" if reason
44
+ super(msg)
45
+ end
46
+ end
47
+
48
+ class RateLimited < ApiError
49
+ attr_reader :retry_after
50
+
51
+ def initialize(message, status: nil, body: nil, retry_after: nil)
52
+ super(message, status: status, body: body)
53
+ @retry_after = retry_after
54
+ end
55
+ end
56
+
57
+ # Caller tried to invoke an action that wasn't declared on the connector.
58
+ # Mirrors UnknownConnector: includes the known set so the frontend can
59
+ # render a helpful error.
60
+ class UnknownAction < Error
61
+ attr_reader :action_key, :connector_key, :known
62
+
63
+ def initialize(action_key, connector_key: nil, known: [])
64
+ @action_key = action_key
65
+ @connector_key = connector_key
66
+ @known = known
67
+ msg = "no action #{action_key.inspect} declared"
68
+ msg += " on connector #{connector_key.inspect}" if connector_key
69
+ msg += ". Known actions: #{known.inspect}" if known.any?
70
+ super(msg)
71
+ end
72
+ end
73
+
74
+ # Action params failed schema validation — missing required fields or
75
+ # extraneous unknown fields. Surfaced as a 422 from the actions endpoint.
76
+ class InvalidActionParams < Error
77
+ attr_reader :missing, :unknown
78
+
79
+ def initialize(missing: [], unknown: [])
80
+ @missing = Array(missing)
81
+ @unknown = Array(unknown)
82
+ parts = []
83
+ parts << "missing required params: #{@missing.join(', ')}" if @missing.any?
84
+ parts << "unknown params: #{@unknown.join(', ')}" if @unknown.any?
85
+ super(parts.join("; ").presence || "invalid action params")
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,13 @@
1
+ module Connectors
2
+ # Shared by HTTP endpoints and services invoked by workers.
3
+ class GrantPolicy
4
+ ROLE_RANK = { "viewer" => 0, "editor" => 1, "owner" => 2 }.freeze
5
+
6
+ def self.role_for(grant, owner:, principals:)
7
+ return unless owner
8
+ return "owner" if grant.owner_type == owner.class.polymorphic_name && grant.owner_id == owner.id
9
+ roles = grant.shares.for_principals(principals).pluck(:role)
10
+ roles.max_by { |role| ROLE_RANK.fetch(role) }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,50 @@
1
+ module Connectors
2
+ module MCP
3
+ class Access
4
+ RANK = GrantPolicy::ROLE_RANK
5
+ attr_reader :grant, :actor, :principals
6
+
7
+ def initialize(grant:, actor:, principals: nil)
8
+ @grant, @actor = grant, actor
9
+ @principals = principals || (actor ? [ [ actor.class.polymorphic_name, actor.id ] ] : [])
10
+ end
11
+
12
+ def require!(role = :viewer)
13
+ grant.reload
14
+ raise AccessDenied, "MCP grant is unavailable" unless Registry.fetch(grant.connector_key).mcp? && !grant.revoked?
15
+ actual = role_for
16
+ raise AccessDenied, "MCP grant requires #{role} access" unless actual && RANK.fetch(actual) >= RANK.fetch(role.to_s)
17
+ self
18
+ rescue ActiveRecord::RecordNotFound, Connectors::UnknownConnector
19
+ raise AccessDenied, "MCP grant is unavailable"
20
+ end
21
+
22
+ def role_for
23
+ GrantPolicy.role_for(grant, owner: actor, principals: principals)
24
+ end
25
+
26
+ def fingerprint
27
+ data = canonical(configuration.except("mcp_oauth"))
28
+ Digest::SHA256.hexdigest(JSON.generate([ grant.owner_type, grant.owner_id, grant.is_managed, grant.external_ref, data ]))
29
+ end
30
+
31
+ def configuration
32
+ ConnectionConfig.resolve(grant.credentials_hash, connector: Registry.fetch(grant.connector_key), internal: true)
33
+ end
34
+
35
+ def actor_key
36
+ actor.to_global_id.to_s
37
+ end
38
+
39
+ private
40
+
41
+ def canonical(value)
42
+ case value
43
+ when Hash then value.sort_by { |key, _| key.to_s }.to_h.transform_values { |item| canonical(item) }
44
+ when Array then value.map { |item| canonical(item) }
45
+ else value
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,177 @@
1
+ module Connectors
2
+ module MCP
3
+ class Authorization
4
+ def initialize(grant:, actor:, principals: nil)
5
+ @access = Access.new(grant: grant, actor: actor, principals: principals)
6
+ end
7
+
8
+ def start(challenge: {})
9
+ @access.require!(:owner)
10
+ data = @access.configuration
11
+ raise ConfigurationRequired, "Connection does not use OAuth" unless data["auth_mode"] == "oauth"
12
+ fingerprint = @access.fingerprint
13
+ resource, metadata = AuthorizationDiscovery.new(server_url: data.fetch("server_url"), issuer: data["authorization_server"]).call(challenge)
14
+ unless Array(metadata["code_challenge_methods_supported"]).include?("S256")
15
+ raise ConfigurationRequired, "Authorization server must advertise PKCE S256"
16
+ end
17
+ callback = callback_url
18
+ HTTP.new.validate_url!(callback)
19
+ scopes = requested_scopes(data, resource, metadata, challenge)
20
+ client = register_client(data, metadata, callback)
21
+ pkce = ::MCP::Client::OAuth::PKCE.generate
22
+ state = SecureRandom.urlsafe_base64(32)
23
+ @access.require!(:owner)
24
+ raise AccessDenied, "MCP configuration changed" unless fingerprint == @access.fingerprint
25
+ payload = { "metadata" => metadata, "client" => client, "resource" => ::MCP::Client::OAuth::Discovery.canonicalize_url(data.fetch("server_url")),
26
+ "callback" => callback, "code_verifier" => pkce.fetch(:code_verifier), "requested_scopes" => scopes }
27
+ Connectors::McpAuthorization.create!(grant: @access.grant, actor_key: @access.actor_key, fingerprint: fingerprint,
28
+ state_digest: Digest::SHA256.hexdigest(state), expires_at: Connectors.configuration.mcp.transaction_ttl.from_now, payload: payload)
29
+ uri = URI(metadata.fetch("authorization_endpoint"))
30
+ query = URI.decode_www_form(uri.query.to_s).to_h.merge("response_type" => "code", "client_id" => client.fetch("client_id"),
31
+ "redirect_uri" => callback, "state" => state, "code_challenge" => pkce.fetch(:code_challenge), "code_challenge_method" => "S256", "resource" => payload.fetch("resource"))
32
+ query["scope"] = scopes.join(" ") if scopes.any?
33
+ uri.query = URI.encode_www_form(query)
34
+ uri.to_s
35
+ end
36
+
37
+ def complete(state:, code: nil, iss: nil, error: nil)
38
+ @access.require!(:owner)
39
+ record = Connectors::McpAuthorization.find_by(state_digest: Digest::SHA256.hexdigest(state.to_s))
40
+ raise AccessDenied, "Invalid MCP authorization state" unless record
41
+ payload = record.claim!(@access)
42
+ claimed = true
43
+ metadata = payload.fetch("metadata")
44
+ if (iss && iss != metadata["issuer"]) || (iss.nil? && metadata["authorization_response_iss_parameter_supported"] == true)
45
+ raise AccessDenied, "Authorization response issuer mismatch"
46
+ end
47
+ raise AccessDenied, "Authorization was denied or incomplete" if error || code.to_s.empty?
48
+ locked do
49
+ @access.require!(:owner)
50
+ raise AccessDenied, "MCP configuration changed" unless record.fingerprint == @access.fingerprint
51
+ tokens = TokenEndpoint.exchange(metadata: metadata, client: resolve_client(payload.fetch("client")), params: {
52
+ "grant_type" => "authorization_code", "code" => code, "redirect_uri" => payload.fetch("callback"),
53
+ "code_verifier" => payload.fetch("code_verifier"), "resource" => payload.fetch("resource") })
54
+ persist(payload, tokens)
55
+ end
56
+ record.update!(status: "complete", payload: { "cleared" => true })
57
+ true
58
+ ensure
59
+ record.update!(status: "failed", payload: { "cleared" => true }) if claimed && record.status == "claimed"
60
+ end
61
+
62
+ def access_token
63
+ @access.require!
64
+ stored = @access.grant.credentials_hash["mcp_oauth"]
65
+ raise AuthorizationRequired unless stored && stored["fingerprint"] == @access.fingerprint
66
+ tokens = stored.fetch("tokens")
67
+ return tokens.fetch("access_token") unless tokens["expires_at"] && tokens["expires_at"] <= Time.current.to_f + 30
68
+ refresh(expected_token: tokens["access_token"])
69
+ end
70
+
71
+ def refresh(expected_token:)
72
+ invalid = false
73
+ token = locked do
74
+ @access.require!
75
+ stored = @access.grant.credentials_hash["mcp_oauth"]
76
+ raise AuthorizationRequired unless stored && stored["fingerprint"] == @access.fingerprint
77
+ previous = stored.fetch("tokens")
78
+ return previous.fetch("access_token") unless previous["access_token"] == expected_token
79
+ raise AuthorizationRequired if previous["refresh_token"].blank?
80
+ begin
81
+ tokens = TokenEndpoint.exchange(metadata: stored.fetch("metadata"), client: resolve_client(stored.fetch("client")), params: {
82
+ "grant_type" => "refresh_token", "refresh_token" => previous.fetch("refresh_token"), "resource" => stored.fetch("resource") })
83
+ rescue HTTPError => error
84
+ raise unless error.status == 400 && invalid_grant?(error.body)
85
+ # Clear under the same lock, and commit before reporting reconnect.
86
+ @access.grant.update_credentials!("mcp_oauth" => nil)
87
+ invalid = true
88
+ next
89
+ end
90
+ tokens["refresh_token"] = previous["refresh_token"] unless tokens.key?("refresh_token")
91
+ persist(stored, tokens)
92
+ tokens.fetch("access_token")
93
+ end
94
+ raise AuthorizationRequired if invalid
95
+ token
96
+ end
97
+
98
+ private
99
+
100
+ def locked(&block)
101
+ Timeout.timeout(Connectors.configuration.mcp.request_timeout, TransportError, "MCP authorization deadline exceeded") do
102
+ @access.grant.with_lock(&block)
103
+ end
104
+ end
105
+
106
+ def resolve_client(client)
107
+ return client unless client["configured"]
108
+ configured = @access.grant.credentials_hash["client_information"]
109
+ unless configured && configured["client_id"] == client["client_id"] && configured["issuer"] == client["issuer"]
110
+ raise ConfigurationRequired, "Configured OAuth client changed"
111
+ end
112
+ configured
113
+ end
114
+
115
+ def invalid_grant?(body)
116
+ JSON.parse(body)["error"] == "invalid_grant"
117
+ rescue JSON::ParserError, TypeError
118
+ false
119
+ end
120
+
121
+ def persist(payload, tokens)
122
+ value = payload.slice("metadata", "client", "resource", "requested_scopes").merge("tokens" => tokens, "fingerprint" => @access.fingerprint)
123
+ @access.grant.update_credentials!("mcp_oauth" => value)
124
+ @access.grant.update!(expires_at: tokens["expires_at"] && Time.at(tokens["expires_at"]), status: :active)
125
+ end
126
+
127
+ def callback_url
128
+ Connectors.configuration.resolved_mcp_callback_url
129
+ end
130
+
131
+ def requested_scopes(data, resource, metadata, challenge)
132
+ scopes = if challenge.key?("scope")
133
+ challenge.fetch("scope").to_s.split
134
+ else
135
+ Array(resource["scopes_supported"])
136
+ end
137
+ scopes |= Array(data.dig("mcp_oauth", "requested_scopes"))
138
+ if Array(metadata["scopes_supported"]).include?("offline_access")
139
+ scopes |= [ "offline_access" ]
140
+ else
141
+ scopes -= [ "offline_access" ]
142
+ end
143
+ raise ProtocolError, "Invalid OAuth scopes" unless scopes.all? { |s| s.is_a?(String) && s.match?(/\A[\x21\x23-\x5B\x5D-\x7E]+\z/) }
144
+ scopes
145
+ end
146
+
147
+ def register_client(data, metadata, callback)
148
+ client = data["client_information"] || data.dig("mcp_oauth", "client")
149
+ if client
150
+ raise ConfigurationRequired, "OAuth client issuer mismatch" unless client["issuer"] == metadata["issuer"]
151
+ return client.slice("client_id", "issuer", "token_endpoint_auth_method").merge("configured" => true) if data["client_information"]
152
+ return client
153
+ end
154
+ document_url = Connectors.configuration.mcp.client_metadata_url
155
+ if document_url && metadata["client_id_metadata_document_supported"] == true
156
+ uri = HTTP.new.validate_url!(document_url)
157
+ raise ConfigurationRequired, "CIMD requires an HTTPS document path" unless uri.scheme == "https" && uri.path.present? && uri.path != "/" && !uri.query
158
+ document = HTTP.new.json(url: document_url)
159
+ unless document["client_id"] == document_url && document["client_name"].is_a?(String) && Array(document["redirect_uris"]).include?(callback)
160
+ raise ConfigurationRequired, "Invalid client metadata document"
161
+ end
162
+ return document.slice("client_id", "token_endpoint_auth_method").merge("issuer" => metadata.fetch("issuer"))
163
+ end
164
+ endpoint = metadata["registration_endpoint"] or raise ConfigurationRequired, "Pre-registered client information is required"
165
+ grant_types = [ "authorization_code", "refresh_token" ]
166
+ grant_types &= Array(metadata["grant_types_supported"]) if metadata.key?("grant_types_supported")
167
+ raise ConfigurationRequired, "Authorization server does not support authorization_code" unless grant_types.include?("authorization_code")
168
+ client = HTTP.new.json(url: endpoint, method: :post, headers: { "Content-Type" => "application/json" }, body: {
169
+ client_name: Connectors.configuration.mcp.client_name, redirect_uris: [ callback ], grant_types: grant_types,
170
+ response_types: [ "code" ], token_endpoint_auth_method: "none", application_type: "web"
171
+ }.to_json)
172
+ raise ProtocolError, "Registration returned no client ID" unless client["client_id"].is_a?(String) && client["client_id"].present?
173
+ client.merge("issuer" => metadata.fetch("issuer"))
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,25 @@
1
+ module Connectors
2
+ module MCP
3
+ # Carries a server-issued challenge from a failed call to the owner's consent screen.
4
+ # The browser cannot replace discovery URLs or scopes inside it.
5
+ module AuthorizationContext
6
+ module_function
7
+
8
+ def encode(grant:, challenge:, fingerprint: nil)
9
+ access = Access.new(grant: grant, actor: grant.owner)
10
+ OAuth::State.encode(connector_key: "mcp", owner_gid: grant.owner.to_global_id.to_s,
11
+ extra: { "purpose" => "mcp_challenge", "grant_id" => grant.id, "fingerprint" => fingerprint || access.fingerprint, "challenge" => challenge })
12
+ end
13
+
14
+ def decode(token, access:)
15
+ access.require!(:owner)
16
+ state = OAuth::State.decode(token)
17
+ extra = state&.dig("x")
18
+ unless state && state["k"] == "mcp" && state["o"] == access.actor_key && extra.is_a?(Hash) && extra["purpose"] == "mcp_challenge" && extra["grant_id"] == access.grant.id && extra["fingerprint"] == access.fingerprint
19
+ raise AccessDenied, "Invalid MCP authorization context"
20
+ end
21
+ extra.fetch("challenge")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,42 @@
1
+ module Connectors
2
+ module MCP
3
+ class AuthorizationDiscovery
4
+ SDK = ::MCP::Client::OAuth::Discovery
5
+
6
+ def initialize(server_url:, issuer: nil)
7
+ @server_url, @issuer = server_url, issuer
8
+ @http = HTTP.new
9
+ end
10
+
11
+ def call(challenge = {})
12
+ @http.validate_url!(@server_url)
13
+ resource = fetch_first(SDK.protected_resource_metadata_urls(server_url: @server_url, resource_metadata_url: challenge["resource_metadata"]))
14
+ unless resource["resource"].is_a?(String) && SDK.resource_covers?(prm: resource["resource"], server: @server_url)
15
+ raise ConfigurationRequired, "Protected resource metadata does not cover this MCP server"
16
+ end
17
+ issuers = resource["authorization_servers"]
18
+ raise ConfigurationRequired, "No authorization server advertised" unless issuers.is_a?(Array) && issuers.any? && issuers.all? { |v| v.is_a?(String) }
19
+ issuer = @issuer || issuers.first
20
+ raise ConfigurationRequired, "Configured issuer is not advertised" unless issuers.include?(issuer)
21
+ @http.validate_url!(issuer)
22
+ metadata = fetch_first(SDK.authorization_server_metadata_urls(issuer))
23
+ raise ConfigurationRequired, "Authorization issuer mismatch" unless metadata["issuer"] == issuer
24
+ %w[authorization_endpoint token_endpoint].each { |key| @http.validate_url!(metadata.fetch(key, "")) }
25
+ [ resource, metadata ]
26
+ end
27
+
28
+ private
29
+
30
+ def fetch_first(urls)
31
+ urls.each do |url|
32
+ begin
33
+ return @http.json(url: url, headers: { "Accept" => "application/json" })
34
+ rescue HTTPError => error
35
+ raise unless [ 404, 405 ].include?(error.status)
36
+ end
37
+ end
38
+ raise ConfigurationRequired, "MCP authorization metadata unavailable"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,36 @@
1
+ module Connectors
2
+ module MCP
3
+ class Cancellation
4
+ def initialize
5
+ @mutex = Mutex.new
6
+ @cancelled = false
7
+ @callbacks = {}
8
+ end
9
+
10
+ def cancel
11
+ callbacks = @mutex.synchronize do
12
+ @cancelled = true
13
+ @callbacks.values.dup
14
+ end
15
+ callbacks.each(&:call)
16
+ end
17
+
18
+ def check!
19
+ raise Cancelled, "MCP request cancelled" if @mutex.synchronize { @cancelled }
20
+ end
21
+
22
+ def attach(&abort)
23
+ @mutex.synchronize do
24
+ raise Cancelled, "MCP request cancelled" if @cancelled
25
+ key = Object.new
26
+ @callbacks[key] = abort
27
+ key
28
+ end
29
+ end
30
+
31
+ def detach(key)
32
+ @mutex.synchronize { @callbacks.delete(key) }
33
+ end
34
+ end
35
+ end
36
+ end