diff --git a/app/controllers/account_configs_controller.rb b/app/controllers/account_configs_controller.rb
index cf128ef9..66d0967b 100644
--- a/app/controllers/account_configs_controller.rb
+++ b/app/controllers/account_configs_controller.rb
@@ -23,7 +23,8 @@ class AccountConfigsController < ApplicationController
AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::COMBINE_PDF_RESULT_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY,
- AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY
+ AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY,
+ AccountConfig::ENABLE_MCP_KEY
].freeze
InvalidKey = Class.new(StandardError)
diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb
new file mode 100644
index 00000000..8d7ca288
--- /dev/null
+++ b/app/controllers/mcp_controller.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+class McpController < ActionController::API
+ before_action :authenticate_user!
+ before_action :verify_mcp_enabled!
+
+ before_action do
+ authorize!(:manage, :mcp)
+ end
+
+ def call
+ return head :ok if request.raw_post.blank?
+
+ body = JSON.parse(request.raw_post)
+
+ result = Mcp::HandleRequest.call(body, current_user, current_ability)
+
+ if result
+ render json: result
+ else
+ head :accepted
+ end
+ rescue CanCan::AccessDenied
+ render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden
+ rescue JSON::ParserError
+ render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request
+ end
+
+ private
+
+ def authenticate_user!
+ render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
+ end
+
+ def verify_mcp_enabled!
+ return if Docuseal.multitenant?
+
+ return if AccountConfig.exists?(account_id: current_user.account_id,
+ key: AccountConfig::ENABLE_MCP_KEY,
+ value: true)
+
+ render json: { error: 'MCP is disabled' }, status: :forbidden
+ end
+
+ def current_user
+ @current_user ||= user_from_api_key
+ end
+
+ def user_from_api_key
+ token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
+
+ return if token.blank?
+
+ sha256 = Digest::SHA256.hexdigest(token)
+
+ User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
+ end
+end
diff --git a/app/controllers/mcp_settings_controller.rb b/app/controllers/mcp_settings_controller.rb
new file mode 100644
index 00000000..a6d7597e
--- /dev/null
+++ b/app/controllers/mcp_settings_controller.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class McpSettingsController < ApplicationController
+ load_and_authorize_resource :mcp_token, parent: false
+
+ before_action do
+ authorize!(:manage, :mcp)
+ end
+
+ def index
+ @mcp_tokens = @mcp_tokens.active.order(id: :desc)
+ end
+
+ def create
+ @mcp_token = current_user.mcp_tokens.new(mcp_token_params)
+
+ if @mcp_token.save
+ flash[:mcp_token] = @mcp_token.token
+
+ redirect_back fallback_location: settings_mcp_index_path, notice: I18n.t('mcp_token_has_been_created')
+ else
+ render turbo_stream: turbo_stream.replace(:modal, template: 'mcp_settings/new'), status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ @mcp_token.update!(archived_at: Time.current)
+
+ redirect_back fallback_location: settings_mcp_index_path, notice: I18n.t('mcp_token_has_been_removed')
+ end
+
+ private
+
+ def mcp_token_params
+ params.require(:mcp_token).permit(:name)
+ end
+end
diff --git a/app/models/account_config.rb b/app/models/account_config.rb
index b7305f87..7781e293 100644
--- a/app/models/account_config.rb
+++ b/app/models/account_config.rb
@@ -57,6 +57,7 @@ class AccountConfig < ApplicationRecord
DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format'
TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields'
POLICY_LINKS_KEY = 'policy_links'
+ ENABLE_MCP_KEY = 'enable_mcp'
EMAIL_VARIABLES = {
SUBMITTER_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,
diff --git a/app/models/mcp_token.rb b/app/models/mcp_token.rb
new file mode 100644
index 00000000..5f4556b2
--- /dev/null
+++ b/app/models/mcp_token.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: mcp_tokens
+#
+# id :bigint not null, primary key
+# archived_at :datetime
+# name :string not null
+# sha256 :string not null
+# token_prefix :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# user_id :bigint not null
+#
+# Indexes
+#
+# index_mcp_tokens_on_sha256 (sha256) UNIQUE
+# index_mcp_tokens_on_user_id (user_id)
+#
+# Foreign Keys
+#
+# fk_rails_... (user_id => users.id)
+#
+class McpToken < ApplicationRecord
+ TOKEN_LENGTH = 43
+
+ belongs_to :user
+
+ before_validation :set_sha256_and_token_prefix, on: :create
+
+ attribute :token, :string, default: -> { SecureRandom.base58(TOKEN_LENGTH) }
+
+ scope :active, -> { where(archived_at: nil) }
+
+ private
+
+ def set_sha256_and_token_prefix
+ self.sha256 = Digest::SHA256.hexdigest(token)
+ self.token_prefix = token[0, 5]
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 7eabb059..b80ae769 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -62,6 +62,7 @@ class User < ApplicationRecord
belongs_to :account
has_one :access_token, dependent: :destroy
has_many :access_tokens, dependent: :destroy
+ has_many :mcp_tokens, dependent: :destroy
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :template_folders, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :user_configs, dependent: :destroy
diff --git a/app/views/mcp_settings/index.html.erb b/app/views/mcp_settings/index.html.erb
new file mode 100644
index 00000000..b276cf0d
--- /dev/null
+++ b/app/views/mcp_settings/index.html.erb
@@ -0,0 +1,112 @@
+
+ <%= render 'shared/settings_nav' %>
+
+
+
+ <%= t('mcp_server') %>
+
+
+
+ <%= link_to new_settings_mcp_path, class: 'btn btn-primary btn-md gap-2 w-full md:w-fit', data: { turbo_frame: 'modal' } do %>
+ <%= svg_icon('plus', class: 'w-6 h-6') %>
+ <%= t('new_token') %>
+ <% end %>
+
+
+
+ <% if flash[:mcp_token].present? %>
+
+
+
+
+
+
+ <%= render 'shared/clipboard_copy', icon: 'copy', text: flash[:mcp_token], class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
+
+
+
+
+
+ <%= t('instructions') %>
+
+
+
+
<%= t('connect_to_docuseal_mcp') %>
+
<%= t('add_the_following_to_your_mcp_client_configuration') %>:
+
+ <% text = JSON.pretty_generate({ mcpServers: { docuseal: { type: 'http', url: "#{root_url(Docuseal.default_url_options)}mcp", headers: { Authorization: "Bearer #{flash[:mcp_token]}" } } } }).strip %>
+
+ <%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
+
+
<%== HighlightCode.call(text, 'JSON', theme: 'base16.dark') %>
+
+
<%= t('works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client') %>
+
+
+
+
+ <% end %>
+
+
+
+
+ |
+ <%= t('name') %>
+ |
+
+ <%= t('token') %>
+ |
+
+ <%= t('created_at') %>
+ |
+
+ |
+
+
+
+ <% @mcp_tokens.each do |mcp_token| %>
+
+ |
+ <%= mcp_token.name %>
+ |
+
+ <% if flash[:mcp_token].present? && mcp_token.token_prefix == flash[:mcp_token][0, 5] %>
+ <%= flash[:mcp_token] %>
+ <% else %>
+ <%= "#{mcp_token.token_prefix}#{'*' * 38}" %>
+ <% end %>
+ |
+
+ <%= l(mcp_token.created_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>
+ |
+
+ <%= button_to settings_mcp_path(mcp_token), method: :delete, class: 'btn btn-outline btn-error btn-xs', title: t('remove'), data: { turbo_confirm: t('are_you_sure_') } do %>
+ <%= t('remove') %>
+ <% end %>
+ |
+
+ <% end %>
+
+
+
+ <% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ENABLE_MCP_KEY) %>
+ <% if can?(:manage, account_config) %>
+ <%= form_for account_config, url: account_configs_path, method: :post do |f| %>
+ <%= f.hidden_field :key %>
+
+
+ <%= t('enable_mcp_server') %>
+
+ <%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
+
+
+
+ <%= f.check_box :value, class: 'toggle', checked: account_config.value == true %>
+
+
+ <% end %>
+ <% end %>
+
+
diff --git a/app/views/mcp_settings/new.html.erb b/app/views/mcp_settings/new.html.erb
new file mode 100644
index 00000000..d9c23899
--- /dev/null
+++ b/app/views/mcp_settings/new.html.erb
@@ -0,0 +1,13 @@
+<%= render 'shared/turbo_modal', title: t('new_token') do %>
+ <%= form_for @mcp_token, url: settings_mcp_index_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
+
+
+ <%= f.label :name, t('name'), class: 'label' %>
+ <%= f.text_field :name, required: true, class: 'base-input w-full', dir: 'auto' %>
+
+
+ <%= f.button button_title, class: 'base-button' %>
+
+
+ <% end %>
+<% end %>
diff --git a/app/views/shared/_settings_nav.html.erb b/app/views/shared/_settings_nav.html.erb
index 94b033d1..5edf8270 100644
--- a/app/views/shared/_settings_nav.html.erb
+++ b/app/views/shared/_settings_nav.html.erb
@@ -90,6 +90,11 @@
<%= link_to 'SSO', settings_sso_index_path, class: 'text-base hover:bg-base-300' %>
<% end %>
+ <% if !Docuseal.multitenant? && can?(:read, McpToken) && can?(:manage, :mcp) %>
+
+ <%= link_to 'MCP', settings_mcp_index_path, class: 'text-base hover:bg-base-300' %>
+
+ <% end %>
<%= render 'shared/settings_nav_extra2' %>
<% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'w-full' } do |f| %>
diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml
index 1dcebe5c..22342867 100644
--- a/config/locales/i18n.yml
+++ b/config/locales/i18n.yml
@@ -895,6 +895,18 @@ en: &en
redo: Redo
add_variable: Add variable
enter_a_url_or_variable_name: Enter a URL or variable name
+ new_token: New token
+ token: Token
+ mcp_server: MCP Server
+ instructions: Instructions
+ please_copy_the_token_below_now_as_it_wont_be_shown_again: Please copy the token below now, as it won't be shown again
+ mcp_token_has_been_created: MCP token has been created.
+ mcp_token_has_been_removed: MCP token has been removed.
+ enable_mcp_server: Enable MCP server
+ all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled: All existing MCP connections will be stopped immediately when this setting is disabled.
+ connect_to_docuseal_mcp: Connect to DocuSeal MCP
+ add_the_following_to_your_mcp_client_configuration: Add the following to your MCP client configuration
+ works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Works with Claude Desktop, Cursor, Windsurf, VS Code, and any MCP-compatible client.
devise:
confirmations:
confirmed: Your email address has been successfully confirmed.
@@ -992,6 +1004,8 @@ en: &en
scopes:
write: Update your data
read: Read your data
+ mcp: Use MCP
+ claudeai: Use Claude AI
pagination:
submissions:
range_with_total: "%{from}-%{to} of %{count} submissions"
@@ -1898,6 +1912,18 @@ es: &es
redo: Rehacer
add_variable: Agregar variable
enter_a_url_or_variable_name: Ingrese una URL o nombre de variable
+ new_token: Nuevo token
+ token: Token
+ mcp_server: Servidor MCP
+ instructions: Instrucciones
+ please_copy_the_token_below_now_as_it_wont_be_shown_again: Copie el token a continuación ahora, ya que no se mostrará de nuevo
+ mcp_token_has_been_created: El token MCP ha sido creado.
+ mcp_token_has_been_removed: El token MCP ha sido eliminado.
+ enable_mcp_server: Habilitar servidor MCP
+ all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled: Todas las conexiones MCP existentes se detendrán inmediatamente cuando se desactive esta configuración.
+ connect_to_docuseal_mcp: Conectar a DocuSeal MCP
+ add_the_following_to_your_mcp_client_configuration: Agregue lo siguiente a la configuración de su cliente MCP
+ works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funciona con Claude Desktop, Cursor, Windsurf, VS Code y cualquier cliente compatible con MCP.
devise:
confirmations:
confirmed: Tu dirección de correo electrónico ha sido confirmada correctamente.
@@ -1995,6 +2021,8 @@ es: &es
scopes:
write: Actualizar tus datos
read: Leer tus datos
+ mcp: Usar MCP
+ claudeai: Usar Claude AI
pagination:
submissions:
range_with_total: "%{from}-%{to} de %{count} envíos"
@@ -2902,6 +2930,18 @@ it: &it
redo: Ripeti
add_variable: Aggiungi variabile
enter_a_url_or_variable_name: Inserisci un URL o nome variabile
+ new_token: Nuovo token
+ token: Token
+ mcp_server: Server MCP
+ instructions: Istruzioni
+ please_copy_the_token_below_now_as_it_wont_be_shown_again: Copia il token qui sotto ora, poiché non verrà mostrato di nuovo
+ mcp_token_has_been_created: Il token MCP è stato creato.
+ mcp_token_has_been_removed: Il token MCP è stato rimosso.
+ enable_mcp_server: Abilita server MCP
+ all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled: Tutte le connessioni MCP esistenti verranno interrotte immediatamente quando questa impostazione viene disattivata.
+ connect_to_docuseal_mcp: Connetti a DocuSeal MCP
+ add_the_following_to_your_mcp_client_configuration: Aggiungi quanto segue alla configurazione del tuo client MCP
+ works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funziona con Claude Desktop, Cursor, Windsurf, VS Code e qualsiasi client compatibile con MCP.
devise:
confirmations:
confirmed: Il tuo indirizzo email è stato confermato con successo.
@@ -2999,6 +3039,8 @@ it: &it
scopes:
write: Aggiorna i tuoi dati
read: Leggi i tuoi dati
+ mcp: Usa MCP
+ claudeai: Usa Claude AI
pagination:
submissions:
range_with_total: "%{from}-%{to} di %{count} invii"
@@ -3902,6 +3944,18 @@ fr: &fr
redo: Rétablir
add_variable: Ajouter une variable
enter_a_url_or_variable_name: Entrez une URL ou un nom de variable
+ new_token: Nouveau jeton
+ token: Jeton
+ mcp_server: Serveur MCP
+ instructions: Instructions
+ please_copy_the_token_below_now_as_it_wont_be_shown_again: Copiez le jeton ci-dessous maintenant, car il ne sera plus affiché
+ mcp_token_has_been_created: Le jeton MCP a été créé.
+ mcp_token_has_been_removed: Le jeton MCP a été supprimé.
+ enable_mcp_server: Activer le serveur MCP
+ all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled: Toutes les connexions MCP existantes seront arrêtées immédiatement lorsque ce paramètre est désactivé.
+ connect_to_docuseal_mcp: Se connecter à DocuSeal MCP
+ add_the_following_to_your_mcp_client_configuration: Ajoutez ce qui suit à la configuration de votre client MCP
+ works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Fonctionne avec Claude Desktop, Cursor, Windsurf, VS Code et tout client compatible MCP.
devise:
confirmations:
confirmed: Votre adresse e-mail a été confirmée avec succès.
@@ -3999,6 +4053,8 @@ fr: &fr
scopes:
write: Mettre à jour vos données
read: Lire vos données
+ mcp: Utiliser MCP
+ claudeai: Utiliser Claude AI
pagination:
submissions:
range_with_total: "%{from}-%{to} sur %{count} soumissions"
@@ -4905,6 +4961,18 @@ pt: &pt
redo: Refazer
add_variable: Adicionar variável
enter_a_url_or_variable_name: Digite uma URL ou nome de variável
+ new_token: Novo token
+ token: Token
+ mcp_server: Servidor MCP
+ instructions: Instruções
+ please_copy_the_token_below_now_as_it_wont_be_shown_again: Copie o token abaixo agora, pois ele não será exibido novamente
+ mcp_token_has_been_created: O token MCP foi criado.
+ mcp_token_has_been_removed: O token MCP foi removido.
+ enable_mcp_server: Ativar servidor MCP
+ all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled: Todas as conexões MCP existentes serão interrompidas imediatamente quando esta configuração for desativada.
+ connect_to_docuseal_mcp: Conectar ao DocuSeal MCP
+ add_the_following_to_your_mcp_client_configuration: Adicione o seguinte à configuração do seu cliente MCP
+ works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funciona com Claude Desktop, Cursor, Windsurf, VS Code e qualquer cliente compatível com MCP.
devise:
confirmations:
confirmed: Seu endereço de e-mail foi confirmado com sucesso.
@@ -5002,6 +5070,8 @@ pt: &pt
scopes:
write: Atualizar seus dados
read: Ler seus dados
+ mcp: Usar MCP
+ claudeai: Usar Claude AI
pagination:
submissions:
range_with_total: "%{from}-%{to} de %{count} submissões"
@@ -5908,6 +5978,18 @@ de: &de
redo: Wiederholen
add_variable: Variable hinzufügen
enter_a_url_or_variable_name: Geben Sie eine URL oder einen Variablennamen ein
+ new_token: Neues Token
+ token: Token
+ mcp_server: MCP-Server
+ instructions: Anweisungen
+ please_copy_the_token_below_now_as_it_wont_be_shown_again: Kopieren Sie das Token jetzt, da es nicht erneut angezeigt wird
+ mcp_token_has_been_created: Das MCP-Token wurde erstellt.
+ mcp_token_has_been_removed: Das MCP-Token wurde entfernt.
+ enable_mcp_server: MCP-Server aktivieren
+ all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled: Alle bestehenden MCP-Verbindungen werden sofort gestoppt, wenn diese Einstellung deaktiviert wird.
+ connect_to_docuseal_mcp: Mit DocuSeal MCP verbinden
+ add_the_following_to_your_mcp_client_configuration: Fügen Sie Folgendes zu Ihrer MCP-Client-Konfiguration hinzu
+ works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funktioniert mit Claude Desktop, Cursor, Windsurf, VS Code und jedem MCP-kompatiblen Client.
devise:
confirmations:
confirmed: Ihre E-Mail-Adresse wurde erfolgreich bestätigt.
@@ -6005,6 +6087,8 @@ de: &de
scopes:
write: Aktualisieren Sie Ihre Daten
read: Lesen Sie Ihre Daten
+ mcp: MCP verwenden
+ claudeai: Claude AI verwenden
pagination:
submissions:
range_with_total: "%{from}-%{to} von %{count} Einreichungen"
@@ -7296,6 +7380,18 @@ nl: &nl
redo: Opnieuw
add_variable: Variabele toevoegen
enter_a_url_or_variable_name: Voer een URL of variabelenaam in
+ new_token: Nieuw token
+ token: Token
+ mcp_server: MCP-server
+ instructions: Instructies
+ please_copy_the_token_below_now_as_it_wont_be_shown_again: Kopieer het token hieronder nu, want het wordt niet opnieuw getoond
+ mcp_token_has_been_created: Het MCP-token is aangemaakt.
+ mcp_token_has_been_removed: Het MCP-token is verwijderd.
+ enable_mcp_server: MCP-server inschakelen
+ all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled: Alle bestaande MCP-verbindingen worden onmiddellijk gestopt wanneer deze instelling wordt uitgeschakeld.
+ connect_to_docuseal_mcp: Verbinden met DocuSeal MCP
+ add_the_following_to_your_mcp_client_configuration: Voeg het volgende toe aan uw MCP-clientconfiguratie
+ works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Werkt met Claude Desktop, Cursor, Windsurf, VS Code en elke MCP-compatibele client.
devise:
confirmations:
confirmed: Je e-mailadres is succesvol bevestigd.
@@ -7393,6 +7489,8 @@ nl: &nl
scopes:
write: Uw gegevens bijwerken
read: Uw gegevens lezen
+ mcp: MCP gebruiken
+ claudeai: Claude AI gebruiken
pagination:
submissions:
range_with_total: "%{from}-%{to} van %{count} inzendingen"
diff --git a/config/routes.rb b/config/routes.rb
index dd2b7da2..ff7e1089 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -168,6 +168,7 @@ Rails.application.routes.draw do
resources :storage, only: %i[index create], controller: 'storage_settings'
resources :search_entries_reindex, only: %i[create]
resources :sms, only: %i[index], controller: 'sms_settings'
+ resources :mcp, only: %i[index new create destroy], controller: 'mcp_settings'
end
if Docuseal.demo? || !Docuseal.multitenant?
resources :api, only: %i[index create], controller: 'api_settings'
@@ -201,6 +202,8 @@ Rails.application.routes.draw do
end
end
+ match '/mcp', to: 'mcp#call', via: %i[get post]
+
get '/js/:filename', to: 'embed_scripts#show', as: :embed_script
ActiveSupport.run_load_hooks(:routes, self)
diff --git a/db/migrate/20260224120000_add_pkce_to_doorkeeper_access_grants.rb b/db/migrate/20260224120000_add_pkce_to_doorkeeper_access_grants.rb
new file mode 100644
index 00000000..6520a118
--- /dev/null
+++ b/db/migrate/20260224120000_add_pkce_to_doorkeeper_access_grants.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class AddPkceToDoorkeeperAccessGrants < ActiveRecord::Migration[7.1]
+ def change
+ add_column :oauth_access_grants, :code_challenge, :string, null: true
+ add_column :oauth_access_grants, :code_challenge_method, :string, null: true
+ end
+end
diff --git a/db/migrate/20260226193537_create_mcp_tokens.rb b/db/migrate/20260226193537_create_mcp_tokens.rb
new file mode 100644
index 00000000..56101286
--- /dev/null
+++ b/db/migrate/20260226193537_create_mcp_tokens.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class CreateMcpTokens < ActiveRecord::Migration[8.1]
+ def change
+ create_table :mcp_tokens do |t|
+ t.references :user, null: false, foreign_key: true, index: true
+ t.string :name, null: false
+ t.string :sha256, null: false, index: { unique: true }
+ t.string :token_prefix, null: false
+ t.datetime :archived_at
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index f45c3331..e13acd79 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_02_16_162053) do
+ActiveRecord::Schema[8.1].define(version: 2026_02_26_193537) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "plpgsql"
@@ -249,6 +249,18 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_16_162053) do
t.index ["key"], name: "index_lock_events_on_key"
end
+ create_table "mcp_tokens", force: :cascade do |t|
+ t.datetime "archived_at"
+ t.datetime "created_at", null: false
+ t.string "name", null: false
+ t.string "sha256", null: false
+ t.string "token_prefix", null: false
+ t.datetime "updated_at", null: false
+ t.bigint "user_id", null: false
+ t.index ["sha256"], name: "index_mcp_tokens_on_sha256", unique: true
+ t.index ["user_id"], name: "index_mcp_tokens_on_user_id"
+ end
+
create_table "oauth_access_grants", force: :cascade do |t|
t.bigint "resource_owner_id", null: false
t.bigint "application_id", null: false
@@ -258,6 +270,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_16_162053) do
t.string "scopes", default: "", null: false
t.datetime "created_at", null: false
t.datetime "revoked_at"
+ t.string "code_challenge"
+ t.string "code_challenge_method"
t.index ["application_id"], name: "index_oauth_access_grants_on_application_id"
t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id"
t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true
diff --git a/lib/ability.rb b/lib/ability.rb
index a721e089..da472f58 100644
--- a/lib/ability.rb
+++ b/lib/ability.rb
@@ -20,6 +20,9 @@ class Ability
can :manage, UserConfig, user_id: user.id
can :manage, Account, id: user.account_id
can :manage, AccessToken, user_id: user.id
+ can :manage, McpToken, user_id: user.id
can :manage, WebhookUrl, account_id: user.account_id
+
+ can :manage, :mcp
end
end
diff --git a/lib/mcp/handle_request.rb b/lib/mcp/handle_request.rb
new file mode 100644
index 00000000..7149fd06
--- /dev/null
+++ b/lib/mcp/handle_request.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module Mcp
+ module HandleRequest
+ TOOLS = [
+ Mcp::Tools::SearchTemplates,
+ Mcp::Tools::CreateTemplate,
+ Mcp::Tools::SendDocuments,
+ Mcp::Tools::SearchDocuments
+ ].freeze
+
+ TOOLS_SCHEMA = TOOLS.map { |t| t::SCHEMA }
+
+ TOOLS_INDEX = TOOLS.index_by { |t| t::SCHEMA[:name] }
+
+ module_function
+
+ # rubocop:disable Metrics/MethodLength
+ def call(body, current_user, current_ability)
+ case body['method']
+ when 'initialize'
+ {
+ jsonrpc: '2.0',
+ id: body['id'],
+ result: {
+ protocolVersion: '2025-11-25',
+ serverInfo: {
+ name: 'DocuSeal',
+ version: Docuseal.version.to_s
+ },
+ capabilities: {
+ tools: {
+ listChanged: false
+ }
+ }
+ }
+ }
+ when 'notifications/initialized'
+ nil
+ when 'ping'
+ { jsonrpc: '2.0', id: body['id'], result: {} }
+ when 'tools/list'
+ { jsonrpc: '2.0', id: body['id'], result: { tools: TOOLS_SCHEMA } }
+ when 'tools/call'
+ tool = TOOLS_INDEX[body.dig('params', 'name')]
+
+ raise "Unknown tool: #{body.dig('params', 'name')}" unless tool
+
+ result = tool.call(body.dig('params', 'arguments') || {}, current_user, current_ability)
+
+ { jsonrpc: '2.0', id: body['id'], result: }
+ else
+ {
+ jsonrpc: '2.0',
+ id: body['id'],
+ error: {
+ code: -32_601,
+ message: "Method not found: #{body['method']}"
+ }
+ }
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+ end
+end
diff --git a/lib/mcp/tools/create_template.rb b/lib/mcp/tools/create_template.rb
new file mode 100644
index 00000000..0a945264
--- /dev/null
+++ b/lib/mcp/tools/create_template.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+module Mcp
+ module Tools
+ module CreateTemplate
+ SCHEMA = {
+ name: 'create_template',
+ title: 'Create Template',
+ description: 'Create a template from a PDF. Provide a URL or base64-encoded file content.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ url: {
+ type: 'string',
+ description: 'URL of the document file to upload'
+ },
+ file: {
+ type: 'string',
+ description: 'Base64-encoded file content'
+ },
+ filename: {
+ type: 'string',
+ description: 'Filename with extension (required when using file)'
+ },
+ name: {
+ type: 'string',
+ description: 'Template name (defaults to filename)'
+ }
+ }
+ },
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: false,
+ openWorldHint: false
+ }
+ }.freeze
+
+ module_function
+
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
+ def call(arguments, current_user, current_ability)
+ current_ability.authorize!(:create, Template.new(account_id: current_user.account_id, author: current_user))
+
+ account = current_user.account
+
+ if arguments['file'].present?
+ tempfile = Tempfile.new
+ tempfile.binmode
+ tempfile.write(Base64.decode64(arguments['file']))
+ tempfile.rewind
+
+ filename = arguments['filename'] || 'document.pdf'
+ elsif arguments['url'].present?
+ tempfile = Tempfile.new
+ tempfile.binmode
+ tempfile.write(DownloadUtils.call(arguments['url'], validate: true).body)
+ tempfile.rewind
+
+ filename = File.basename(URI.decode_www_form_component(arguments['url']))
+ else
+ return { content: [{ type: 'text', text: 'Provide either url or file' }], isError: true }
+ end
+
+ file = ActionDispatch::Http::UploadedFile.new(
+ tempfile:,
+ filename:,
+ type: Marcel::MimeType.for(tempfile)
+ )
+
+ template = Template.new(
+ account:,
+ author: current_user,
+ folder: account.default_template_folder,
+ name: arguments['name'].presence || File.basename(filename, '.*')
+ )
+
+ template.save!
+
+ documents, = Templates::CreateAttachments.call(template, { files: [file] }, extract_fields: true)
+ schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
+
+ if template.fields.blank?
+ template.fields = Templates::ProcessDocument.normalize_attachment_fields(template, documents)
+ end
+
+ template.update!(schema:)
+
+ WebhookUrls.enqueue_events(template, 'template.created')
+
+ SearchEntries.enqueue_reindex(template)
+
+ {
+ content: [
+ {
+ type: 'text',
+ text: {
+ id: template.id,
+ name: template.name,
+ edit_url: Rails.application.routes.url_helpers.edit_template_url(template,
+ **Docuseal.default_url_options)
+ }.to_json
+ }
+ ]
+ }
+ end
+ end
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
+ end
+end
diff --git a/lib/mcp/tools/search_documents.rb b/lib/mcp/tools/search_documents.rb
new file mode 100644
index 00000000..bf1f56f5
--- /dev/null
+++ b/lib/mcp/tools/search_documents.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module Mcp
+ module Tools
+ module SearchDocuments
+ SCHEMA = {
+ name: 'search_documents',
+ title: 'Search Documents',
+ description: 'Search signed or pending documents by submitter name, email, phone, or template name',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ q: {
+ type: 'string',
+ description: 'Search by submitter name, email, phone, or template name'
+ },
+ limit: {
+ type: 'integer',
+ description: 'The number of results to return (default 10)'
+ }
+ },
+ required: %w[q]
+ },
+ annotations: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false
+ }
+ }.freeze
+
+ module_function
+
+ def call(arguments, current_user, current_ability)
+ submissions = Submissions.search(current_user, Submission.accessible_by(current_ability).active,
+ arguments['q'], search_template: true)
+
+ limit = arguments.fetch('limit', 10).to_i
+ limit = 10 if limit <= 0
+ limit = [limit, 100].min
+ submissions = submissions.preload(:submitters, :template)
+ .order(id: :desc)
+ .limit(limit)
+
+ data = submissions.map do |submission|
+ url = Rails.application.routes.url_helpers.submission_url(
+ submission.id, **Docuseal.default_url_options
+ )
+
+ {
+ id: submission.id,
+ template_name: submission.template&.name,
+ status: Submissions::SerializeForApi.build_status(submission, submission.submitters),
+ submitters: submission.submitters.map do |s|
+ { email: s.email, name: s.name, phone: s.phone, status: s.status }
+ end,
+ documents_url: url
+ }
+ end
+
+ { content: [{ type: 'text', text: data.to_json }] }
+ end
+ end
+ end
+end
diff --git a/lib/mcp/tools/search_templates.rb b/lib/mcp/tools/search_templates.rb
new file mode 100644
index 00000000..9d9bbe52
--- /dev/null
+++ b/lib/mcp/tools/search_templates.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module Mcp
+ module Tools
+ module SearchTemplates
+ SCHEMA = {
+ name: 'search_templates',
+ title: 'Search Templates',
+ description: 'Search document templates by name',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ q: {
+ type: 'string',
+ description: 'Search query to filter templates by name'
+ },
+ limit: {
+ type: 'integer',
+ description: 'The number of templates to return (default 10)'
+ }
+ },
+ required: %w[q]
+ },
+ annotations: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false
+ }
+ }.freeze
+
+ module_function
+
+ def call(arguments, current_user, current_ability)
+ templates = Templates.search(current_user, Template.accessible_by(current_ability).active, arguments['q'])
+
+ limit = arguments.fetch('limit', 10).to_i
+ limit = 10 if limit <= 0
+ limit = [limit, 100].min
+ templates = templates.order(id: :desc).limit(limit)
+
+ {
+ content: [
+ {
+ type: 'text',
+ text: templates.map { |t| { id: t.id, name: t.name } }.to_json
+ }
+ ]
+ }
+ end
+ end
+ end
+end
diff --git a/lib/mcp/tools/send_documents.rb b/lib/mcp/tools/send_documents.rb
new file mode 100644
index 00000000..25b1be72
--- /dev/null
+++ b/lib/mcp/tools/send_documents.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+module Mcp
+ module Tools
+ module SendDocuments
+ SCHEMA = {
+ name: 'send_documents',
+ title: 'Send Documents',
+ description: 'Send a document template for signing to specified submitters',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ template_id: {
+ type: 'integer',
+ description: 'Template identifier'
+ },
+ submitters: {
+ type: 'array',
+ description: 'The list of submitters (signers)',
+ items: {
+ type: 'object',
+ properties: {
+ email: {
+ type: 'string',
+ description: 'Submitter email address'
+ },
+ name: {
+ type: 'string',
+ description: 'Submitter name'
+ },
+ phone: {
+ type: 'string',
+ description: 'Submitter phone number in E.164 format'
+ }
+ }
+ }
+ }
+ },
+ required: %w[template_id submitters]
+ },
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: false,
+ openWorldHint: true
+ }
+ }.freeze
+
+ module_function
+
+ # rubocop:disable Metrics/MethodLength
+ def call(arguments, current_user, current_ability)
+ template = Template.accessible_by(current_ability).find_by(id: arguments['template_id'])
+
+ return { content: [{ type: 'text', text: 'Template not found' }], isError: true } unless template
+
+ current_ability.authorize!(:create, Submission.new(template:, account_id: current_user.account_id))
+
+ return { content: [{ type: 'text', text: 'Template has no fields' }], isError: true } if template.fields.blank?
+
+ submitters = (arguments['submitters'] || []).map do |s|
+ s.slice('email', 'name', 'role', 'phone')
+ .compact_blank
+ .with_indifferent_access
+ end
+
+ submissions = Submissions.create_from_submitters(
+ template:,
+ user: current_user,
+ source: :api,
+ submitters_order: 'random',
+ submissions_attrs: { submitters: submitters },
+ params: { 'send_email' => true, 'submitters' => submitters }
+ )
+
+ if submissions.blank?
+ return { content: [{ type: 'text', text: 'No valid submitters provided' }], isError: true }
+ end
+
+ WebhookUrls.enqueue_events(submissions, 'submission.created')
+
+ Submissions.send_signature_requests(submissions)
+
+ submissions.each do |submission|
+ submission.submitters.each do |submitter|
+ next unless submitter.completed_at?
+
+ ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id,
+ 'send_invitation_email' => false)
+ end
+ end
+
+ SearchEntries.enqueue_reindex(submissions)
+
+ submission = submissions.first
+
+ {
+ content: [
+ {
+ type: 'text',
+ text: {
+ id: submission.id,
+ status: 'pending'
+ }.to_json
+ }
+ ]
+ }
+ rescue Submissions::CreateFromSubmitters::BaseError => e
+ { content: [{ type: 'text', text: e.message }], isError: true }
+ end
+ # rubocop:enable Metrics/MethodLength
+ end
+ end
+end