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,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
|