add MCP support

pull/556/merge
Alex Turchyn 2 weeks ago committed by GitHub
parent 961f09e092
commit 62a969d8fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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)

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

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

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

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

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

@ -0,0 +1,112 @@
<div class="flex-wrap space-y-4 md:flex md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %>
<div class="md:flex-grow">
<div class="flex flex-col md:flex-row md:flex-wrap gap-2 md:justify-between md:items-end mb-4 min-h-12">
<h1 class="text-4xl font-bold">
<%= t('mcp_server') %>
</h1>
<div class="flex flex-col md:flex-row gap-y-2 gap-x-4 md:items-center">
<div class="tooltip">
<%= 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') %>
<span><%= t('new_token') %></span>
<% end %>
</div>
</div>
</div>
<% if flash[:mcp_token].present? %>
<div class="space-y-4 mb-4">
<div class="card bg-base-200">
<div class="card-body p-6">
<label for="mcp_token" class="text-sm font-semibold">
<%= t('please_copy_the_token_below_now_as_it_wont_be_shown_again') %>:
</label>
<div class="flex w-full space-x-4">
<input id="mcp_token" type="text" value="<%= flash[:mcp_token] %>" class="input font-mono input-bordered w-full" autocomplete="off" readonly>
<%= 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') %>
</div>
</div>
</div>
<div class="space-y-4">
<p class="text-2xl font-bold">
<%= t('instructions') %>
</p>
<div class="card bg-base-200/60 border-2 border-info">
<div class="card-body p-6">
<p class="text-2xl font-semibold"><%= t('connect_to_docuseal_mcp') %></p>
<p class="text-lg"><%= t('add_the_following_to_your_mcp_client_configuration') %>:</p>
<div class="mockup-code overflow-hidden">
<% text = JSON.pretty_generate({ mcpServers: { docuseal: { type: 'http', url: "#{root_url(Docuseal.default_url_options)}mcp", headers: { Authorization: "Bearer #{flash[:mcp_token]}" } } } }).strip %>
<span class="top-0 right-0 absolute">
<%= 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') %>
</span>
<pre class="before:!m-0 pl-4 pb-4"><code class="overflow-hidden w-full"><%== HighlightCode.call(text, 'JSON', theme: 'base16.dark') %></code></pre>
</div>
<p class="text-lg"><%= t('works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client') %></p>
</div>
</div>
</div>
</div>
<% end %>
<div class="overflow-x-auto">
<table class="table w-full table-lg rounded-b-none overflow-hidden">
<thead class="bg-base-200">
<tr class="text-neutral uppercase">
<th>
<%= t('name') %>
</th>
<th>
<%= t('token') %>
</th>
<th>
<%= t('created_at') %>
</th>
<th class="text-right" width="1px">
</th>
</tr>
</thead>
<tbody>
<% @mcp_tokens.each do |mcp_token| %>
<tr scope="row">
<td>
<%= mcp_token.name %>
</td>
<td>
<% if flash[:mcp_token].present? && mcp_token.token_prefix == flash[:mcp_token][0, 5] %>
<%= flash[:mcp_token] %>
<% else %>
<%= "#{mcp_token.token_prefix}#{'*' * 38}" %>
<% end %>
</td>
<td>
<%= l(mcp_token.created_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>
</td>
<td class="flex items-center space-x-2 justify-end">
<%= 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 %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% 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 %>
<div class="flex items-center gap-4 py-2.5">
<div class="flex items-center space-x-1">
<span class="text-left"><%= t('enable_mcp_server') %></span>
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled') %>">
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
</span>
</div>
<submit-form data-on="change" class="flex">
<%= f.check_box :value, class: 'toggle', checked: account_config.value == true %>
</submit-form>
</div>
<% end %>
<% end %>
</div>
</div>

@ -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| %>
<div class="space-y-4">
<div class="w-full">
<%= f.label :name, t('name'), class: 'label' %>
<%= f.text_field :name, required: true, class: 'base-input w-full', dir: 'auto' %>
</div>
<div class="form-control pt-2">
<%= f.button button_title, class: 'base-button' %>
</div>
</div>
<% end %>
<% end %>

@ -90,6 +90,11 @@
<%= link_to 'SSO', settings_sso_index_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% if !Docuseal.multitenant? && can?(:read, McpToken) && can?(:manage, :mcp) %>
<li>
<%= link_to 'MCP', settings_mcp_index_path, class: 'text-base hover:bg-base-300' %>
</li>
<% 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| %>

@ -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"

@ -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)

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

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

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

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

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

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

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

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

@ -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
Loading…
Cancel
Save