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,31 @@
1
+ module Connectors
2
+ # One sharing entry per (grant, principal) pair. The engine intentionally
3
+ # doesn't know what a principal is — `principal_type` / `principal_id`
4
+ # are opaque tuples supplied by the host's
5
+ # `Connectors.configuration.principal_resolver` block.
6
+ #
7
+ # `role` mirrors n8n's enterprise model: viewer = read-only, editor =
8
+ # update + use, owner = full control (delete + share + transfer). Only
9
+ # one `:owner` per grant — enforced by the unique index on
10
+ # `(grant_id, principal_type, principal_id)` plus a custom uniqueness
11
+ # validation per role.
12
+ class CredentialShare < ApplicationRecord
13
+ self.table_name = "connectors_credential_shares"
14
+
15
+ belongs_to :grant, class_name: "Connectors::Grant"
16
+
17
+ ROLES = %w[viewer editor owner].freeze
18
+ validates :role, inclusion: { in: ROLES }
19
+ validates :principal_type, :principal_id, presence: true
20
+ validates :principal_id, uniqueness: { scope: %i[grant_id principal_type] }
21
+
22
+ # `principals` is an array of [type, id] tuples, as returned by the
23
+ # host's `principal_resolver`. Returns shares matching ANY of them.
24
+ scope :for_principals, ->(principals) {
25
+ next none if principals.blank?
26
+ clause = principals.map { "(principal_type = ? AND principal_id = ?)" }.join(" OR ")
27
+ args = principals.flat_map { |type, id| [ type.to_s, id ] }
28
+ where(clause, *args)
29
+ }
30
+ end
31
+ end
@@ -0,0 +1,103 @@
1
+ module Connectors
2
+ # A Grant is the user-authorized link between an owner (User, Workspace, etc.)
3
+ # and a third-party service. It stores the encrypted tokens / API keys needed
4
+ # to call that service's API on the owner's behalf.
5
+ class Grant < ApplicationRecord
6
+ serialize :credentials, coder: JSON, type: Hash
7
+ encrypts :credentials
8
+
9
+ belongs_to :owner, polymorphic: true
10
+ has_many :shares, class_name: "Connectors::CredentialShare", dependent: :destroy
11
+ has_many :webhook_events, class_name: "Connectors::WebhookEvent", dependent: :destroy
12
+ has_many :poll_states, class_name: "Connectors::PollState", dependent: :destroy
13
+
14
+ enum :status, { active: 0, expired: 1, revoked: 2, errored: 3 }, default: :active
15
+
16
+ validates :connector_key, presence: true
17
+
18
+ scope :for_connector, ->(key) { where(connector_key: key.to_s) }
19
+ scope :expiring_within, ->(window) { where(expires_at: ..window.from_now).where.not(expires_at: nil) }
20
+
21
+ # Returns an instance of the connector class registered for this grant's key.
22
+ def connector
23
+ Connectors::Registry.fetch(connector_key).new(self)
24
+ end
25
+
26
+ # Cached clients must observe revocation by another worker. Read only the
27
+ # status so pending changes to credentials or polling state are preserved.
28
+ def ensure_not_revoked!
29
+ revoked_in_database = persisted? && self.class.uncached { self.class.where(id: id, status: :revoked).exists? }
30
+ raise Connectors::AuthenticationFailed, "connection has been revoked" if revoked? || revoked_in_database
31
+ end
32
+
33
+ # Atomic merge into the encrypted credentials hash. Always go through this
34
+ # instead of mutating the hash in-place, since serialized columns don't
35
+ # dirty-track mutation. Invalidates the per-instance resolved cache so a
36
+ # follow-up `credentials_hash` re-blends the vault values.
37
+ def update_credentials!(patch)
38
+ patch = patch.stringify_keys
39
+ with_persistence_lock do
40
+ attrs = { credentials: (credentials || {}).merge(patch) }
41
+ attrs[:expires_at] = patch["expires_at"] && Time.at(patch["expires_at"].to_i) if patch.key?("expires_at")
42
+ update!(attrs)
43
+ clear_resolved_credentials
44
+ end
45
+ end
46
+
47
+ def reload(...)
48
+ clear_resolved_credentials
49
+ super
50
+ end
51
+
52
+ # Unsaved candidates can use the same primitives without attempting a row
53
+ # lock. Persisted grants always re-read under a database lock.
54
+ def with_persistence_lock(&block)
55
+ persisted? ? with_lock(&block) : yield
56
+ end
57
+
58
+ # Convenience accessor that always returns a hash, never nil. When the
59
+ # grant is `is_managed?`, the host's `secrets_resolver` is invoked and
60
+ # its return value is merged over the DB-stored credentials — the vault
61
+ # wins, so rotating a value in Vault propagates without a DB write.
62
+ # Cached per-instance to keep one request = one vault lookup.
63
+ def credentials_hash
64
+ return @resolved_credentials_hash if defined?(@resolved_credentials_hash)
65
+ base = credentials || {}
66
+ @resolved_credentials_hash = is_managed? ? base.merge(Connectors.configuration.resolve_secrets(self)) : base
67
+ end
68
+
69
+ # The raw, DB-only credentials hash (never consults the vault). Used by
70
+ # the index serializer to compute `__overwritten_properties` without
71
+ # tripping the resolver on every list response.
72
+ def stored_credentials_hash
73
+ credentials || {}
74
+ end
75
+
76
+ # Mirrors n8n's `getWorkflowStaticData('node')` — a small scratch hash
77
+ # the connector mutates between webhook lifecycle calls (or polling
78
+ # cursors). Save with `save!` after mutation, or use
79
+ # `update_static_data!(group, &block)` for the atomic path.
80
+ def static_data_hash
81
+ static_data || {}
82
+ end
83
+
84
+ # Yields the sub-hash for a webhook/polling group, then persists any
85
+ # mutation in a single UPDATE. Group defaults to "default" so single-
86
+ # webhook providers don't need to think about it.
87
+ def update_static_data!(group = "default")
88
+ with_persistence_lock do
89
+ data = static_data_hash.deep_dup
90
+ sub = (data[group.to_s] ||= {})
91
+ result = yield(sub)
92
+ update!(static_data: data)
93
+ result
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def clear_resolved_credentials
100
+ remove_instance_variable(:@resolved_credentials_hash) if defined?(@resolved_credentials_hash)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,6 @@
1
+ module Connectors
2
+ class McpAuthorization < ApplicationRecord
3
+ REQUIRED_ROLE = :owner
4
+ include MCP::PendingTransaction
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module Connectors
2
+ class McpInteraction < ApplicationRecord
3
+ REQUIRED_ROLE = :editor
4
+ include MCP::PendingTransaction
5
+ end
6
+ end
@@ -0,0 +1,17 @@
1
+ module Connectors
2
+ # One cursor/configuration per consumer of a connection. instance_key is an
3
+ # opaque identifier supplied by the host (for example its trigger ID).
4
+ class PollState < ApplicationRecord
5
+ belongs_to :grant, class_name: "Connectors::Grant"
6
+ validates :instance_key, presence: true
7
+
8
+ def update_cursor!
9
+ with_lock do
10
+ cursor = data.deep_dup
11
+ result = yield(cursor)
12
+ update!(data: cursor)
13
+ result
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ module Connectors
2
+ # One record per inbound webhook from a provider. Persisted before
3
+ # processing so handlers can crash without losing the event, and so the
4
+ # idempotency index (connector_key, external_event_id) drops duplicates.
5
+ class WebhookEvent < ApplicationRecord
6
+ belongs_to :grant, class_name: "Connectors::Grant"
7
+
8
+ enum :status, { received: 0, processed: 1, failed: 2, ignored: 3 }, default: :received
9
+
10
+ validates :connector_key, :received_at, presence: true
11
+
12
+ scope :for_connector, ->(key) { where(connector_key: key.to_s) }
13
+ scope :unprocessed, -> { where(status: %i[received failed]) }
14
+
15
+ def payload_hash
16
+ payload || {}
17
+ end
18
+ end
19
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,68 @@
1
+ Connectors::Engine.routes.draw do
2
+ get "/credentials/:id/mcp/tools", to: "mcp#tools"
3
+ post "/credentials/:id/mcp/tools/call", to: "mcp#call_tool"
4
+ post "/credentials/:id/mcp/interactions/resume", to: "mcp#resume"
5
+ post "/credentials/:id/mcp/authorize", to: "mcp#authorize"
6
+ get "/mcp/oauth/callback", to: "mcp#callback"
7
+ post "/mcp/oauth/callback", to: "mcp#callback"
8
+ # Catalog + connected-account introspection. Declared first so /grants and
9
+ # /types aren't interpreted as a :connector_key.
10
+ # n8n-shape credential CRUD (the canonical endpoints — frontend uses these).
11
+ get "/credentials", to: "credentials#index", as: :credentials
12
+ get "/credentials/for-workflow", to: "credentials#for_workflow", as: :credentials_for_workflow
13
+ post "/credentials/test", to: "credentials#test_unsaved", as: :test_unsaved_credential
14
+ get "/credentials/new", to: "credentials#new", as: :new_credential
15
+ post "/credentials", to: "credentials#create"
16
+ get "/credentials/:id", to: "credentials#show", as: :credential
17
+ patch "/credentials/:id", to: "credentials#update"
18
+ put "/credentials/:id", to: "credentials#update"
19
+ delete "/credentials/:id", to: "credentials#destroy"
20
+ post "/credentials/:id/revoke", to: "oauth#revoke", as: :revoke_credential
21
+
22
+ # Action manifest + invocation. Per-credential because actions need an
23
+ # authenticated grant to run against; the catalog at /types/:name also
24
+ # includes a static `actions: [...]` array for design-time browsing.
25
+ get "/credentials/:id/actions", to: "actions#index", as: :credential_actions
26
+ post "/credentials/:id/actions/:name", to: "actions#create", as: :invoke_credential_action,
27
+ constraints: { name: /[^\/]+/ }
28
+
29
+ # Phase 9 — credential sharing / scoping
30
+ put "/credentials/:id/share", to: "credentials#share", as: :share_credential
31
+ delete "/credentials/:id/share", to: "credentials#unshare"
32
+ put "/credentials/:id/transfer", to: "credentials#transfer", as: :transfer_credential
33
+
34
+ # @deprecated `/grants` aliases — keep working until the frontend migrates.
35
+ get "/grants", to: "grants#index", as: :grants
36
+ post "/grants/:id/test", to: "grants#test", as: :test_grant
37
+ post "/grants/:id/webhook_subscribe", to: "grants#webhook_subscribe", as: :webhook_subscribe_grant
38
+ delete "/grants/:id/webhook_subscribe", to: "grants#webhook_unsubscribe"
39
+ post "/grants/:id/poll", to: "grants#poll", as: :poll_grant
40
+ get "/types", to: "types#index", as: :types
41
+ get "/types/:name", to: "types#show", as: :type, constraints: { name: /[^\/]+/ }
42
+
43
+ # Split frontend/backend OAuth completion (Activepieces-style). The
44
+ # frontend's `/oauth/callback` page POSTs `{code, state}` here after the
45
+ # provider redirects to it.
46
+ post "/oauth/exchange", to: "oauth#exchange", as: :oauth_exchange
47
+
48
+ get "/:connector_key/authorize.json", to: "oauth#authorize_json", as: :authorize_json
49
+ get "/:connector_key/authorize", to: "oauth#authorize", as: :authorize
50
+ # Legacy monolithic-host callback — same-origin frontend/backend hosts
51
+ # (n8n-style). Split-origin apps point the redirect_uri at the frontend
52
+ # and POST to /oauth/exchange instead.
53
+ get "/:connector_key/callback", to: "oauth#callback", as: :callback
54
+
55
+ # App-level webhook URL (Slack, GitHub Apps, Linear, Notion, ...): one URL
56
+ # per app, grant resolved from payload via Connector.resolve_grant_from_webhook.
57
+ post "/:connector_key/webhook", to: "webhooks#receive", as: :app_webhook
58
+
59
+ # Phase 7 — multi-webhook providers route by `webhook_name` segment so
60
+ # `webhookMethods.default` and `webhookMethods.setup` get distinct URLs
61
+ # (mirrors n8n's `IWebhookDescription.name` at interfaces.ts:2600).
62
+ post "/:connector_key/webhook/:webhook_name", to: "webhooks#receive", as: :named_app_webhook
63
+
64
+ # Per-grant webhook URL (Stripe, Twilio, ...): grant explicit in URL.
65
+ # Must be declared AFTER /webhook so the bare form isn't shadowed.
66
+ post "/:connector_key/:grant_id/webhook", to: "webhooks#receive", as: :webhook
67
+ post "/:connector_key/:grant_id/webhook/:webhook_name", to: "webhooks#receive", as: :named_webhook
68
+ end
@@ -0,0 +1,49 @@
1
+ class CreateConnectorsGrants < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # Everything is uuid — engine-owned primary keys AND polymorphic owner
4
+ # columns. The host (and any other owner type — Team, Workspace, …) is
5
+ # expected to have uuid PKs too. `gen_random_uuid()` is a PG13+ built-in,
6
+ # no extension required.
7
+ create_table :connectors_grants, id: :uuid do |t|
8
+ t.references :owner, polymorphic: true, type: :uuid, null: false
9
+ t.string :connector_key, null: false
10
+ t.string :display_name
11
+ t.text :credentials # encrypted at rest via ActiveRecord::Encryption
12
+ t.integer :status, null: false, default: 0
13
+ t.datetime :expires_at
14
+ t.datetime :last_used_at
15
+ t.string :external_account_id # provider-side user/account id, if applicable
16
+
17
+ # Per-grant scratch storage for webhook subscriptions + polling cursors
18
+ # (n8n parity: `getWorkflowStaticData('node')` at interfaces.ts:1257-1274).
19
+ # Each `webhook_methods` group writes into its own nested hash keyed
20
+ # by the group name, so multi-webhook providers can persist
21
+ # independent state per subscription type.
22
+ t.jsonb :static_data, null: false, default: {}
23
+
24
+ # External secrets manager integration. A grant either stores its
25
+ # credentials inline (DB-encrypted) OR carries a lightweight
26
+ # `external_ref` that the host's vault adapter resolves on demand.
27
+ # n8n parity: `__overwrittenProperties` on the credential type
28
+ # (interfaces.ts:382), populated by frontend.service.ts:681-705
29
+ # when the external-secrets EE module is active.
30
+ t.string :external_ref
31
+ t.boolean :is_managed, null: false, default: false
32
+
33
+ t.timestamps
34
+ end
35
+
36
+ add_index :connectors_grants,
37
+ [ :owner_type, :owner_id, :connector_key ],
38
+ name: "index_connectors_grants_on_owner_and_connector"
39
+
40
+ add_index :connectors_grants, :connector_key
41
+ add_index :connectors_grants, :expires_at
42
+ add_index :connectors_grants, :status
43
+ add_index :connectors_grants,
44
+ [ :connector_key, :external_account_id ],
45
+ where: "external_account_id IS NOT NULL",
46
+ name: "index_connectors_grants_on_external_account"
47
+ add_index :connectors_grants, :external_ref, where: "external_ref IS NOT NULL"
48
+ end
49
+ end
@@ -0,0 +1,35 @@
1
+ class CreateConnectorsWebhookEvents < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :connectors_webhook_events, id: :uuid do |t|
4
+ t.references :grant, type: :uuid, null: false, foreign_key: { to_table: :connectors_grants }
5
+ t.string :connector_key, null: false
6
+ t.string :external_event_id
7
+ t.jsonb :payload
8
+ t.text :signature
9
+ t.integer :status, null: false, default: 0
10
+ t.text :error_message
11
+ t.datetime :received_at, null: false
12
+ t.datetime :processed_at
13
+
14
+ # Full inbound request shape so `IWebhookFunctions`-style handlers
15
+ # have headers (Stripe-Signature etc.), query string, raw body (for
16
+ # replay / signature debugging), and the named webhook group the
17
+ # request hit (`webhookMethods.default` vs `.setup`).
18
+ t.jsonb :headers, null: false, default: {}
19
+ t.jsonb :query, null: false, default: {}
20
+ t.text :raw_body
21
+ t.string :webhook_name, null: false, default: "default"
22
+
23
+ t.timestamps
24
+ end
25
+
26
+ add_index :connectors_webhook_events, :connector_key
27
+ add_index :connectors_webhook_events, :status
28
+ add_index :connectors_webhook_events, :webhook_name
29
+ add_index :connectors_webhook_events,
30
+ [ :connector_key, :external_event_id ],
31
+ unique: true,
32
+ where: "external_event_id IS NOT NULL",
33
+ name: "idx_connectors_webhook_events_idempotency"
34
+ end
35
+ end
@@ -0,0 +1,26 @@
1
+ class CreateConnectorsCredentialShares < ActiveRecord::Migration[8.1]
2
+ # Phase 9 — credential sharing. One row per (grant, principal) pair.
3
+ # `principal_type` is whatever the host wires through the
4
+ # `Connectors.configuration.principal_resolver` block — it's almost
5
+ # always "User" but EE hosts may share to "Team" / "Project" / etc.
6
+ #
7
+ # n8n parity: enterprise/credentials.controller.ee.ts handles
8
+ # `PUT /credentials/:id/share` + `/transfer`. We give the engine the
9
+ # SAME storage shape without baking in host-app concepts.
10
+ def change
11
+ create_table :connectors_credential_shares, id: :uuid do |t|
12
+ t.references :grant, type: :uuid, null: false, foreign_key: { to_table: :connectors_grants }
13
+ t.string :principal_type, null: false
14
+ t.uuid :principal_id, null: false
15
+ t.string :role, null: false, default: "viewer"
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :connectors_credential_shares, [ :grant_id, :principal_type, :principal_id ],
21
+ unique: true,
22
+ name: "idx_connectors_credential_shares_uniq"
23
+ add_index :connectors_credential_shares, [ :principal_type, :principal_id ],
24
+ name: "idx_connectors_credential_shares_lookup"
25
+ end
26
+ end
@@ -0,0 +1,12 @@
1
+ class CreateConnectorsPollStates < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :connectors_poll_states, id: :uuid do |t|
4
+ t.references :grant, type: :uuid, null: false, foreign_key: { to_table: :connectors_grants }
5
+ t.string :instance_key, null: false
6
+ t.jsonb :data, null: false, default: {}
7
+ t.timestamps
8
+ end
9
+
10
+ add_index :connectors_poll_states, [ :grant_id, :instance_key ], unique: true
11
+ end
12
+ end
@@ -0,0 +1,18 @@
1
+ class CreateConnectorsMcpTransactions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ %i[mcp_authorizations mcp_interactions].each do |name|
4
+ create_table "connectors_#{name}", id: :uuid do |t|
5
+ t.references :grant, type: :uuid, null: false, foreign_key: { to_table: :connectors_grants, on_delete: :cascade }
6
+ t.string :actor_key, null: false
7
+ t.string :fingerprint, null: false
8
+ t.string :status, null: false, default: "pending"
9
+ t.text :payload, null: false
10
+ t.datetime :expires_at, null: false
11
+ t.timestamps
12
+ end
13
+ add_index "connectors_#{name}", :expires_at
14
+ end
15
+ add_column :connectors_mcp_authorizations, :state_digest, :string, null: false
16
+ add_index :connectors_mcp_authorizations, :state_digest, unique: true
17
+ end
18
+ end
@@ -0,0 +1,92 @@
1
+ # Adding a connector
2
+
3
+ Use a REST connector when the backend should implement provider actions against an HTTP API. Use an MCP profile when the provider exposes a compatible remote MCP server and supplies its own tool schemas. See [architecture](architecture.md) for the separation.
4
+
5
+ ## REST connector
6
+
7
+ Choose a unique key and place the class in the host's `app/connectors/<key>/connector.rb`. Match the Ruby namespace to the directory for Zeitwerk. The example below is a fictional CRM template, not a claim about an existing provider API:
8
+
9
+ ```ruby
10
+ # app/connectors/example_crm/connector.rb
11
+ module ExampleCrm
12
+ class Connector < Connectors::Connector
13
+ connector key: :example_crm, auth: :api_key,
14
+ base_url: "https://api.example.test", display_name: "Example CRM"
15
+
16
+ credentials do
17
+ field :api_key, type: "string", required: true, secret: true
18
+ end
19
+
20
+ authenticate type: :generic, properties: {
21
+ headers: { "Authorization" => "=Bearer {{$credentials.api_key}}" }
22
+ }
23
+
24
+ test_request method: :get, url: "me"
25
+
26
+ action :find_contact, display_name: "Find Contact" do
27
+ field :email, type: "string", required: true
28
+ execute do |input|
29
+ client.get("contacts", { email: input.fetch("email") }).body
30
+ end
31
+ end
32
+ end
33
+ end
34
+ ```
35
+
36
+ Replace the endpoint, authentication and action with the provider's documented contract. Use the shared `client` for request timeouts, authentication, revocation, retries and error handling. Do not store real keys in connector classes. Set safe fixed provider URLs; do not turn untrusted action inputs into arbitrary destinations.
37
+
38
+ `test_request` should use a cheap, read-only endpoint compatible with the credential's permissions. A successful test establishes access to that endpoint, not every possible action. Document scope restrictions.
39
+
40
+ REST actions check required values and remove undeclared keys. Add provider-specific validation when required; field types primarily describe the UI. OAuth2/OAuth1, pre-authentication, webhooks, polling and credential inheritance are documented in the [DSL reference](../CONNECTORS_FRAMEWORK.md). Declare only capabilities you actually implement.
41
+
42
+ ## Test first
43
+
44
+ In the gem repository, use `rails_helper` for the dummy application, registry reset and WebMock. In a host project, configure equivalent owner, encryption and database fixtures. With the example connector loaded:
45
+
46
+ ```ruby
47
+ require "rails_helper"
48
+
49
+ RSpec.describe ExampleCrm::Connector do
50
+ let(:owner) { Owner.create!(name: "CRM test") }
51
+ let(:grant) do
52
+ Connectors::Grant.create!(owner: owner, connector_key: "example_crm",
53
+ credentials: { "api_key" => "synthetic-key" })
54
+ end
55
+
56
+ it "finds contacts through the authenticated client" do
57
+ stub_request(:get, "https://api.example.test/contacts")
58
+ .with(query: { email: "person@example.test" },
59
+ headers: { "Authorization" => "Bearer synthetic-key" })
60
+ .to_return(status: 200, headers: { "Content-Type" => "application/json" },
61
+ body: '{"contacts":[]}')
62
+
63
+ result = Connectors::ActionRunner.call(grant, :find_contact,
64
+ { "email" => "person@example.test" })
65
+ expect(result[:status]).to eq("ok")
66
+ expect(result[:data]).to eq("contacts" => [])
67
+ end
68
+ end
69
+ ```
70
+
71
+ Add cases for the provider's documented errors, scope restrictions and required input; verify write operations are not replayed unexpectedly. For webhooks test signature validation, grant routing and duplicate events. For polling test cursor isolation and rollback on failure. Avoid tests that only repeat DSL declarations without verifying behavior.
72
+
73
+ ## Named MCP profile
74
+
75
+ For a server supported by the generic MCP integration, a named profile supplies fixed connection metadata. It does not copy remote tools into Ruby action classes:
76
+
77
+ ```ruby
78
+ # app/connectors/example_mcp/connector.rb
79
+ module ExampleMcp
80
+ class Connector < Connectors::Connector
81
+ connector key: :example_mcp, auth: :api_key, base_url: nil,
82
+ display_name: "Example MCP"
83
+ mcp server_url: "https://mcp.example.test/mcp", auth_mode: "oauth"
84
+ end
85
+ end
86
+ ```
87
+
88
+ The `auth: :api_key` field is the current base connector DSL marker; the `mcp` declaration selects OAuth for the remote connection. It does not enable an API-key fallback. For servers needing preregistered clients, declare the optional secret `client_information` credential field as the shipped ClickUp profile does.
89
+
90
+ Confirm the actual server's transport/protocol support, endpoint, OAuth discovery metadata, scopes and client registration requirements using official documentation and live read-only metadata. Do not assume all servers support dynamic registration, refresh tokens or the same scopes. Test fixed-profile configuration, consent requirements, sanitized credential responses, discovery and invocation. Use the shared MCP services; add no provider-specific token logic unless the actual contract requires it.
91
+
92
+ See [ClickUp](../MCP_CLIENT.md#clickup) for the implemented example and [contributing](../CONTRIBUTING.md) for full verification commands.
@@ -0,0 +1,71 @@
1
+ # Architecture and execution contracts
2
+
3
+ Connectors is a Rails engine. It does not provide a workflow executor, account login UI, scheduler or hosted provider proxy. The [README](../README.md) describes installation; the [framework reference](../CONNECTORS_FRAMEWORK.md) describes the DSL.
4
+
5
+ ## Components
6
+
7
+ | Component | Responsibility |
8
+ | --- | --- |
9
+ | Host application | Authenticated owner/principals, encryption keys, provider application secrets, job backend, scheduling and business workflows |
10
+ | Registry and connector classes | Credential schemas, provider metadata, REST actions, authentication declarations and optional polling/webhooks |
11
+ | `Connectors::Grant` | One connected account; encrypted credentials, status, ownership and provider identity |
12
+ | Credential shares | Grant access for UUID principals with viewer, editor or owner roles |
13
+ | REST execution | Declared actions and Faraday middleware for credential injection, refresh, errors and retries |
14
+ | MCP client | Grant-scoped remote discovery, schema validation, transport, OAuth and durable interactions |
15
+ | Controllers | HTTP access checks and stable request/response envelopes |
16
+
17
+ Classes under `app/connectors/*/connector.rb` register through the connector DSL when Rails loads them. The engine's `to_prepare` hook loads shipped and host connector classes. Remote MCP tools are discovered for each authorized grant; they do not become global connector classes.
18
+
19
+ ## REST actions
20
+
21
+ ```text
22
+ Authenticated app request
23
+ → engine role check (editor or owner)
24
+ → declared action lookup and revocation check
25
+ → required-parameter validation and input whitelist
26
+ → provider action implementation
27
+ → Faraday: rate limit / refresh / error normalization / retries
28
+ → per-attempt revocation check / pre-authentication / credential injection
29
+ → provider
30
+ → normalized action result
31
+ ```
32
+
33
+ `ActionRunner.call(grant, name, input)` is also available to trusted host code. It checks revocation, but it does not accept an actor or perform sharing authorization: the host must authorize the selected grant before calling it. Connector methods using `client` get the same HTTP checks. Provider implementations that bypass `client` also bypass that middleware.
34
+
35
+ Action field declarations describe the form and required values; they are not a general JSON Schema validator. Unknown REST action keys are dropped. Provider actions must validate constraints beyond required values where needed.
36
+
37
+ ## MCP tools
38
+
39
+ ```text
40
+ Authenticated app request
41
+ → MCP access check for the grant and actor
42
+ → discover provider tool descriptors
43
+ → validate arguments against the selected tool's schema
44
+ → transport with grant credentials and protocol metadata
45
+ → provider tools/call
46
+ → native tool result, or persisted input_required interaction
47
+ ```
48
+
49
+ The backend handles discovery, registration, OAuth scopes and token use. The frontend renders tool input and explicit user consent/elicitation; it does not implement provider authentication or requests. Unlike the REST Ruby runner, `MCP::Client.new(grant:, actor:, principals:)` checks access itself. HTTP tools and actions currently have separate endpoints.
50
+
51
+ OAuth and elicitation use encrypted, expiring database records bound to the actor, grant and connection fingerprint. One-time transaction claims prevent local replay. They do not guarantee exactly-once execution at the remote provider. A network interruption can leave a remote operation's outcome unknown. See [MCP support and limits](../MCP_CLIENT.md).
52
+
53
+ ## Persistence and encryption
54
+
55
+ Credentials and MCP transaction payloads use Rails Active Record Encryption. Owner IDs, connection metadata, polling state and webhook bodies are not all encrypted by this feature. Webhook raw bodies/payloads and polling state can contain provider data; hosts must apply their own retention and access rules.
56
+
57
+ Credential merges and OAuth refresh coordinate with row locks. Polling can keep one cursor per `(grant, instance_key)`, so independent consumers do not share progress accidentally. Revocation checks query current persisted status to handle cached clients; in-flight network requests cannot be recalled.
58
+
59
+ ## Webhooks and polling
60
+
61
+ Inbound webhook handling resolves the connector and grant, applies that connector's verification, persists a `WebhookEvent` and queues delivery. `DeliverWebhookJob` invokes the connector handler and optional host hook. Deduplication uses provider event IDs when available. Verification and extraction rules are provider-specific.
62
+
63
+ The host activates/deactivates declared webhook subscriptions and schedules polling. Polling commits cursor changes after a successful provider call; reliable delivery of returned items to downstream workflows is a host concern. No engine process runs an autonomous schedule.
64
+
65
+ ## Errors and operational limits
66
+
67
+ REST actions return typed error envelopes; the HTTP layer maps validation, authentication, permission and quota failures to appropriate statuses. Other provider action failures use 502. Direct Ruby clients raise library errors where no action envelope is involved.
68
+
69
+ MCP preserves tool-level `isError` results separately from HTTP/protocol errors. Upstream HTTP 429 returns `rate_limited` with valid optional retry guidance. MCP transport bounds request duration, body size, pagination and interaction rounds; it validates and pins destinations. See the MCP guide for exact defaults and supported authentication mechanisms.
70
+
71
+ Production acceptance requires verification in the intended host and provider account. Passing package checks demonstrates that consumers can load the distributed artifact; it does not establish provider uptime, sufficient scopes, application authorization or load capacity.
data/docs/releasing.md ADDED
@@ -0,0 +1,60 @@
1
+ # Releasing Connectors
2
+
3
+ The public source repository is [jackhelio/connectors](https://github.com/jackhelio/connectors). The gemspec permits publishing only to `https://rubygems.org`. Releases use GitHub Actions and RubyGems Trusted Publishing; no permanent RubyGems API token is stored in GitHub.
4
+
5
+ ## Account and repository setup
6
+
7
+ Enable [multi-factor authentication](https://guides.rubygems.org/setting-up-multifactor-authentication/) on the maintainer's RubyGems account. Save recovery codes outside the repository.
8
+
9
+ For the first release, create a [pending trusted publisher](https://rubygems.org/profile/oidc/pending_trusted_publishers):
10
+
11
+ | Field | Value |
12
+ | --- | --- |
13
+ | Gem name | `connectors` |
14
+ | Repository owner | `jackhelio` |
15
+ | Repository | `connectors` |
16
+ | Workflow filename | `release.yml` |
17
+ | GitHub environment | `release` |
18
+
19
+ Leave the optional workflow repository fields blank: publication runs directly in this repository's workflow. Pending publishers support the first upload and become regular trusted publishers after publication; an initial manual upload is unnecessary. Creating a pending publisher does not reserve a gem name. See the official [Trusted Publishing guide](https://guides.rubygems.org/trusted-publishing/).
20
+
21
+ Create the matching GitHub environment `release`, with a deployment tag policy allowing `v*`. Keep `main` protected with the `lint` and `test` checks required. Only maintainers with repository write access should create release tags. The release job additionally checks that the tagged commit is on `main` and that the tag matches the gem version.
22
+
23
+ ## Verify a release candidate
24
+
25
+ 1. Update `lib/connectors/version.rb` and the lockfile when changing versions. Finalize the corresponding version's changelog and compatibility notes.
26
+ 2. Run the randomized RSpec suite and RuboCop against a disposable PostgreSQL database as described in [contributing](../CONTRIBUTING.md).
27
+ 3. Verify the distributable package:
28
+
29
+ ```sh
30
+ bundle exec ruby script/verify_package.rb
31
+ bundle exec gem build connectors.gemspec --strict
32
+ gem specification connectors-0.1.0.gem files
33
+ ```
34
+
35
+ Replace the artifact version when preparing a later release. The verification script checks public metadata, required files and documentation links, installs the gem into a temporary directory, and boots a fresh Rails host outside the checkout. It checks the shipped connector registry, migration discovery and MCP schema loading. It neither publishes nor invokes a provider. RSpec runs this same check in CI.
36
+
37
+ 4. Merge the reviewed release changes through a pull request after its required checks pass. Confirm CI on the merged `main` commit also passes.
38
+ 5. Confirm the RubyGems account and publisher configuration above are complete before pushing a tag.
39
+
40
+ The package includes runtime code, migrations, protocol data/licenses and consumer documentation. Tests, the dummy host, development tooling, lockfile, workflows and ignored internal planning documents remain outside the gem. Develop from the source repository, not the installed package.
41
+
42
+ ## Publish the verified commit
43
+
44
+ Fetch the merged commit, confirm its version and green CI, then create an annotated tag pointing to that exact commit:
45
+
46
+ ```sh
47
+ git fetch origin --tags
48
+ git tag -a v0.1.0 <verified-main-commit-sha> -m "Release 0.1.0"
49
+ git push origin v0.1.0
50
+ ```
51
+
52
+ Replace the SHA placeholder and version. Pushing the tag starts `.github/workflows/release.yml`. It reuses the normal CI workflow to run lint and the full RSpec suite before the publishing job starts. The publishing job verifies the tag, ancestry and package, builds the gem strictly, exchanges GitHub OIDC identity for short-lived RubyGems credentials using the official credentials action, then pushes the package. Only this job has `id-token: write`; neither job needs repository write permission.
53
+
54
+ Watch the Release workflow and verify the version, metadata and artifact on [RubyGems](https://rubygems.org/gems/connectors). Install the published version into a clean host before announcing it. A successful test run alone is not proof of publication.
55
+
56
+ If authentication fails before upload, fix the account/publisher configuration and rerun the workflow for the same tag. If upload may have succeeded, check RubyGems before retrying. Published versions cannot be overwritten; subsequent code changes require a new version and tag. Do not move a published release tag. Do not use `bundle exec rake release` as a validation command: it can tag, push and publish outside this workflow.
57
+
58
+ ## Supported baseline
59
+
60
+ The gem declares Ruby 3.2+ and Rails 8.1.3+ within the 8.1 series. CI exercises Ruby 3.4.8 with PostgreSQL 16. Broader compatibility needs its own validation. Host authentication, encryption configuration, provider interoperability and production operations remain separate from package verification.