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 %> +
+ + + + + + + + + + + <% @mcp_tokens.each do |mcp_token| %> + + + + + + + <% end %> + +
+ <%= t('name') %> + + <%= t('token') %> + + <%= t('created_at') %> + +
+ <%= 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 %> +
+
+ <% 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