add MCP support
This commit is contained in:
@@ -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| %>
|
||||
|
||||
Reference in New Issue
Block a user