mirror of https://github.com/docusealco/docuseal
Compare commits
17 Commits
5fe75c84ff
...
7ce6c29f4d
| Author | SHA1 | Date |
|---|---|---|
|
|
7ce6c29f4d | 1 week ago |
|
|
ae50b2e323 | 1 week ago |
|
|
152cbc11a6 | 1 week ago |
|
|
3a406de655 | 1 week ago |
|
|
2e31cda08c | 1 week ago |
|
|
094b7f47bd | 1 week ago |
|
|
99ac349ecc | 1 week ago |
|
|
8830e03cf3 | 1 week ago |
|
|
706f3d6d65 | 1 week ago |
|
|
12b49f5fa7 | 1 week ago |
|
|
85327760b9 | 1 week ago |
|
|
c94ed9c3d4 | 2 weeks ago |
|
|
bc50735d2a | 2 weeks ago |
|
|
d05e125fa9 | 2 weeks ago |
|
|
48d595ca81 | 2 weeks ago |
|
|
a156de5e75 | 2 weeks ago |
|
|
a7003bf4d0 | 2 weeks ago |
@ -0,0 +1,90 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Mcp
|
||||||
|
class CreateTemplateController < McpBaseController
|
||||||
|
SCHEMA = {
|
||||||
|
name: 'create_template',
|
||||||
|
title: 'Create Template',
|
||||||
|
description: 'Create a document template. Provide a URL to upload a PDF/DOCX file, or provide only a name ' \
|
||||||
|
'to create an empty template and receive an edit URL where the file can be uploaded via the UI.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Template name (used as the template name and required when url is not provided)'
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Optional URL of a PDF or DOCX file to upload. If omitted, an empty template is ' \
|
||||||
|
'created and the returned edit_url can be used to upload a file via the UI.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: %w[name]
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: true
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
||||||
|
def call
|
||||||
|
account = current_user.account
|
||||||
|
|
||||||
|
@template = Template.new(
|
||||||
|
account:,
|
||||||
|
author: current_user,
|
||||||
|
folder: account.default_template_folder,
|
||||||
|
source: :mcp,
|
||||||
|
name: mcp_params['name'].to_s.presence || 'New Template',
|
||||||
|
fields: [],
|
||||||
|
schema: []
|
||||||
|
)
|
||||||
|
|
||||||
|
authorize!(:create, @template)
|
||||||
|
|
||||||
|
if mcp_params['url'].present?
|
||||||
|
tempfile = Tempfile.new
|
||||||
|
tempfile.binmode
|
||||||
|
tempfile.write(DownloadUtils.call(mcp_params['url'], validate: true).body)
|
||||||
|
tempfile.rewind
|
||||||
|
|
||||||
|
filename = File.basename(URI.decode_www_form_component(mcp_params['url']))
|
||||||
|
|
||||||
|
file = ActionDispatch::Http::UploadedFile.new(
|
||||||
|
tempfile:,
|
||||||
|
filename:,
|
||||||
|
type: Marcel::MimeType.for(tempfile)
|
||||||
|
)
|
||||||
|
|
||||||
|
@template.name = mcp_params['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:)
|
||||||
|
else
|
||||||
|
@template.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
WebhookUrls.enqueue_events(@template, 'template.created')
|
||||||
|
|
||||||
|
SearchEntries.enqueue_reindex(@template)
|
||||||
|
|
||||||
|
render_tool_result(
|
||||||
|
id: @template.id,
|
||||||
|
name: @template.name,
|
||||||
|
edit_url: edit_template_url(@template)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Mcp
|
||||||
|
class LoadTemplateController < McpBaseController
|
||||||
|
SCHEMA = {
|
||||||
|
name: 'load_template',
|
||||||
|
title: 'Load Template',
|
||||||
|
description: 'Load a template with its fields. Each field includes name, type, and the signing role name.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
template_id: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'Template identifier'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: %w[template_id]
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: false
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def call
|
||||||
|
@template = Template.accessible_by(current_ability).find(mcp_params['template_id'])
|
||||||
|
|
||||||
|
authorize!(:read, @template)
|
||||||
|
|
||||||
|
submitters_index = @template.submitters.index_by { |s| s['uuid'] }
|
||||||
|
|
||||||
|
roles = @template.submitters.pluck('name')
|
||||||
|
|
||||||
|
fields = @template.fields.filter_map do |field|
|
||||||
|
next if field['name'].blank?
|
||||||
|
|
||||||
|
{
|
||||||
|
name: field['name'],
|
||||||
|
type: field['type'],
|
||||||
|
role: submitters_index[field['submitter_uuid']]&.dig('name')
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
render_tool_result(
|
||||||
|
id: @template.id,
|
||||||
|
name: @template.name,
|
||||||
|
roles: roles,
|
||||||
|
fields: fields
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Mcp
|
||||||
|
class McpBaseController < ActionController::API
|
||||||
|
wrap_parameters false
|
||||||
|
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :verify_mcp_enabled!
|
||||||
|
check_authorization
|
||||||
|
|
||||||
|
before_action do
|
||||||
|
raise CanCan::AccessDenied unless can?(:manage, :mcp)
|
||||||
|
end
|
||||||
|
|
||||||
|
rescue_from CanCan::AccessDenied do
|
||||||
|
render_error(-32_603, 'Forbidden', status: :forbidden)
|
||||||
|
end
|
||||||
|
|
||||||
|
rescue_from ActiveRecord::RecordNotFound do
|
||||||
|
render_tool_error('Not found')
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def default_url_options
|
||||||
|
Docuseal.default_url_options
|
||||||
|
end
|
||||||
|
|
||||||
|
def mcp_body
|
||||||
|
request.request_parameters
|
||||||
|
end
|
||||||
|
|
||||||
|
def mcp_params
|
||||||
|
mcp_body.dig('params', 'arguments') || {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_result(result)
|
||||||
|
render json: { jsonrpc: '2.0', id: mcp_body['id'], result: }
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_error(code, message, id: nil, status: :ok)
|
||||||
|
render json: { jsonrpc: '2.0', id:, error: { code:, message: } }, status:
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_tool_result(data)
|
||||||
|
render_result(content: [{ type: 'text', text: data.to_json }])
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_tool_error(message)
|
||||||
|
render_result(content: [{ type: 'text', text: message }], isError: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
end
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Mcp
|
||||||
|
class ProtocolController < McpBaseController
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
|
def ok
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize_request
|
||||||
|
render_result(
|
||||||
|
protocolVersion: '2025-11-25',
|
||||||
|
serverInfo: {
|
||||||
|
name: 'DocuSeal',
|
||||||
|
version: Docuseal.version.to_s
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
tools: {
|
||||||
|
listChanged: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialized_notification
|
||||||
|
head :accepted
|
||||||
|
end
|
||||||
|
|
||||||
|
def ping
|
||||||
|
render_result({})
|
||||||
|
end
|
||||||
|
|
||||||
|
def tools_list
|
||||||
|
render_result(tools: McpController::TOOLS)
|
||||||
|
end
|
||||||
|
|
||||||
|
def method_not_found
|
||||||
|
render_error(-32_601, "Method not found: #{mcp_body['method']}", id: mcp_body['id'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def tool_not_found
|
||||||
|
render_error(-32_602, "Unknown tool: #{mcp_body.dig('params', 'name')}", id: mcp_body['id'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_error
|
||||||
|
render_error(-32_700, 'Parse error', status: :bad_request)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Mcp
|
||||||
|
class SearchDocumentsController < McpBaseController
|
||||||
|
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
|
||||||
|
|
||||||
|
def call
|
||||||
|
authorize!(:read, Submission)
|
||||||
|
|
||||||
|
submissions = Submissions.search(current_user, Submission.accessible_by(current_ability).active,
|
||||||
|
mcp_params['q'], search_template: true)
|
||||||
|
|
||||||
|
limit = mcp_params.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|
|
||||||
|
{
|
||||||
|
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: submission_url(submission.id)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
render_tool_result(data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Mcp
|
||||||
|
class SearchTemplatesController < McpBaseController
|
||||||
|
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
|
||||||
|
|
||||||
|
def call
|
||||||
|
authorize!(:read, Template)
|
||||||
|
|
||||||
|
templates = Templates.search(current_user, Template.accessible_by(current_ability).active, mcp_params['q'])
|
||||||
|
|
||||||
|
limit = mcp_params.fetch('limit', 10).to_i
|
||||||
|
limit = 10 if limit <= 0
|
||||||
|
limit = [limit, 100].min
|
||||||
|
templates = templates.order(id: :desc).limit(limit)
|
||||||
|
|
||||||
|
render_tool_result(templates.map { |t| { id: t.id, name: t.name } })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Mcp
|
||||||
|
class SendDocumentsController < McpBaseController
|
||||||
|
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'
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Signing role name from the template'
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Prefill field values for this submitter (fields become readonly)',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Field name'
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
description: 'Prefilled value for the field'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: %w[name value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: %w[template_id submitters]
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: true
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# rubocop:disable Metrics
|
||||||
|
def call
|
||||||
|
@template = Template.accessible_by(current_ability).find(mcp_params['template_id'])
|
||||||
|
|
||||||
|
authorize!(:read, @template)
|
||||||
|
|
||||||
|
return render_tool_error('Template has been archived') if @template.archived_at?
|
||||||
|
|
||||||
|
authorize!(:create, Submission.new(template: @template, account_id: current_user.account_id))
|
||||||
|
|
||||||
|
return render_tool_error('Template has no fields') if @template.fields.blank?
|
||||||
|
|
||||||
|
submitters = (mcp_params['submitters'] || []).map do |s|
|
||||||
|
attrs = s.slice('email', 'name', 'role', 'phone').compact_blank
|
||||||
|
|
||||||
|
fields = Array.wrap(s['fields']).filter_map do |f|
|
||||||
|
next if f['name'].blank?
|
||||||
|
|
||||||
|
{ 'name' => f['name'], 'default_value' => f['value'], 'readonly' => true }
|
||||||
|
end
|
||||||
|
|
||||||
|
attrs['fields'] = fields if fields.present?
|
||||||
|
|
||||||
|
attrs.with_indifferent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
submissions = Submissions.create_from_submitters(
|
||||||
|
template: @template,
|
||||||
|
user: current_user,
|
||||||
|
source: :mcp,
|
||||||
|
submitters_order: @template.preferences['submitters_order'].presence || 'random',
|
||||||
|
submissions_attrs: { submitters: },
|
||||||
|
params: { 'send_email' => true, 'submitters' => submitters }
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_tool_error('No valid submitters provided') if submissions.blank?
|
||||||
|
|
||||||
|
WebhookUrls.enqueue_events(submissions, 'submission.created')
|
||||||
|
|
||||||
|
Submissions.send_signature_requests(submissions)
|
||||||
|
|
||||||
|
SearchEntries.enqueue_reindex(submissions)
|
||||||
|
|
||||||
|
submission = submissions.first
|
||||||
|
|
||||||
|
render_tool_result(id: submission.id, status: 'pending')
|
||||||
|
rescue Submissions::CreateFromSubmitters::BaseError => e
|
||||||
|
render_tool_error(e.message)
|
||||||
|
end
|
||||||
|
# rubocop:enable Metrics
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -1,58 +1,44 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class McpController < ActionController::API
|
class McpController < ActionController::Metal
|
||||||
before_action :authenticate_user!
|
TOOL_CONTROLLERS = {
|
||||||
before_action :verify_mcp_enabled!
|
'search_templates' => Mcp::SearchTemplatesController,
|
||||||
|
'load_template' => Mcp::LoadTemplateController,
|
||||||
|
'create_template' => Mcp::CreateTemplateController,
|
||||||
|
'send_documents' => Mcp::SendDocumentsController,
|
||||||
|
'search_documents' => Mcp::SearchDocumentsController
|
||||||
|
}.freeze
|
||||||
|
|
||||||
before_action do
|
TOOLS = TOOL_CONTROLLERS.map { |_, controller| controller::SCHEMA }.freeze
|
||||||
authorize!(:manage, :mcp)
|
|
||||||
end
|
|
||||||
|
|
||||||
def call
|
def call
|
||||||
return head :ok if request.raw_post.blank?
|
return Mcp::ProtocolController.dispatch(:ok, request, response) if request.raw_post.blank?
|
||||||
|
|
||||||
body = JSON.parse(request.raw_post)
|
body = JSON.parse(request.raw_post)
|
||||||
|
body = nil unless body.is_a?(Hash)
|
||||||
|
|
||||||
result = Mcp::HandleRequest.call(body, current_user, current_ability)
|
request.request_parameters = body || {}
|
||||||
|
|
||||||
if result
|
action =
|
||||||
render json: result
|
case body&.dig('method')
|
||||||
else
|
when 'initialize' then :initialize_request
|
||||||
head :accepted
|
when 'notifications/initialized' then :initialized_notification
|
||||||
end
|
when 'ping' then :ping
|
||||||
rescue CanCan::AccessDenied
|
when 'tools/list' then :tools_list
|
||||||
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden
|
when 'tools/call'
|
||||||
rescue JSON::ParserError
|
tool = TOOL_CONTROLLERS[body.dig('params', 'name')]
|
||||||
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
return tool.dispatch(:call, request, response) if tool
|
||||||
|
|
||||||
def authenticate_user!
|
:tool_not_found
|
||||||
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
|
else
|
||||||
end
|
:method_not_found
|
||||||
|
end
|
||||||
|
|
||||||
def verify_mcp_enabled!
|
Mcp::ProtocolController.dispatch(action, request, response)
|
||||||
return if Docuseal.multitenant?
|
rescue JSON::ParserError
|
||||||
|
request.request_parameters = {}
|
||||||
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 })
|
Mcp::ProtocolController.dispatch(:parse_error, request, response)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub">
|
<a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub">
|
||||||
<span class="flex items-center justify-between space-x-0.5 font-medium">
|
<span class="flex items-center justify-between space-x-0.5 font-medium">
|
||||||
<%= svg_icon('start', class: 'h-3 w-3') %>
|
<%= svg_icon('start', class: 'h-3 w-3') %>
|
||||||
<span>17k</span>
|
<span>18k</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
Rack::Request.forwarded_priority = %i[x_forwarded]
|
||||||
@ -1,66 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Mcp
|
|
||||||
module HandleRequest
|
|
||||||
TOOLS = [
|
|
||||||
Mcp::Tools::SearchTemplates,
|
|
||||||
Mcp::Tools::LoadTemplate,
|
|
||||||
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
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Mcp
|
|
||||||
module Tools
|
|
||||||
module CreateTemplate
|
|
||||||
SCHEMA = {
|
|
||||||
name: 'create_template',
|
|
||||||
title: 'Create Template',
|
|
||||||
description: 'Create a document template. Provide a URL to upload a PDF/DOCX file, or provide only a name ' \
|
|
||||||
'to create an empty template and receive an edit URL where the file can be uploaded via the UI.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
name: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Template name (used as the template name and required when url is not provided)'
|
|
||||||
},
|
|
||||||
url: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Optional URL of a PDF or DOCX file to upload. If omitted, an empty template is ' \
|
|
||||||
'created and the returned edit_url can be used to upload a file via the UI.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: %w[name]
|
|
||||||
},
|
|
||||||
annotations: {
|
|
||||||
readOnlyHint: false,
|
|
||||||
destructiveHint: false,
|
|
||||||
idempotentHint: false,
|
|
||||||
openWorldHint: true
|
|
||||||
}
|
|
||||||
}.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
|
|
||||||
|
|
||||||
template = Template.new(
|
|
||||||
account:,
|
|
||||||
author: current_user,
|
|
||||||
folder: account.default_template_folder,
|
|
||||||
source: :mcp,
|
|
||||||
name: arguments['name'].to_s.presence || 'New Template',
|
|
||||||
fields: [],
|
|
||||||
schema: []
|
|
||||||
)
|
|
||||||
|
|
||||||
if 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']))
|
|
||||||
|
|
||||||
file = ActionDispatch::Http::UploadedFile.new(
|
|
||||||
tempfile:,
|
|
||||||
filename:,
|
|
||||||
type: Marcel::MimeType.for(tempfile)
|
|
||||||
)
|
|
||||||
|
|
||||||
template.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:)
|
|
||||||
else
|
|
||||||
template.save!
|
|
||||||
end
|
|
||||||
|
|
||||||
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
|
|
||||||
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Mcp
|
|
||||||
module Tools
|
|
||||||
module LoadTemplate
|
|
||||||
SCHEMA = {
|
|
||||||
name: 'load_template',
|
|
||||||
title: 'Load Template',
|
|
||||||
description: 'Load a template with its fields. Each field includes name, type, and the signing role name.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
template_id: {
|
|
||||||
type: 'integer',
|
|
||||||
description: 'Template identifier'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: %w[template_id]
|
|
||||||
},
|
|
||||||
annotations: {
|
|
||||||
readOnlyHint: true,
|
|
||||||
destructiveHint: false,
|
|
||||||
idempotentHint: true,
|
|
||||||
openWorldHint: false
|
|
||||||
}
|
|
||||||
}.freeze
|
|
||||||
|
|
||||||
module_function
|
|
||||||
|
|
||||||
def call(arguments, _current_user, current_ability)
|
|
||||||
template = Template.accessible_by(current_ability).find_by(id: arguments['template_id'])
|
|
||||||
|
|
||||||
if !template || !current_ability.can?(:read, template)
|
|
||||||
return { content: [{ type: 'text', text: 'Template not found' }], isError: true }
|
|
||||||
end
|
|
||||||
|
|
||||||
submitters_index = template.submitters.index_by { |s| s['uuid'] }
|
|
||||||
|
|
||||||
roles = template.submitters.pluck('name')
|
|
||||||
|
|
||||||
fields = template.fields.filter_map do |field|
|
|
||||||
next if field['name'].blank?
|
|
||||||
|
|
||||||
{
|
|
||||||
name: field['name'],
|
|
||||||
type: field['type'],
|
|
||||||
role: submitters_index[field['submitter_uuid']]&.dig('name')
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
{
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: {
|
|
||||||
id: template.id,
|
|
||||||
name: template.name,
|
|
||||||
roles: roles,
|
|
||||||
fields: fields
|
|
||||||
}.to_json
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
# 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'
|
|
||||||
},
|
|
||||||
role: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Signing role name from the template'
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
type: 'array',
|
|
||||||
description: 'Prefill field values for this submitter (fields become readonly)',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
name: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Field name'
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
description: 'Prefilled value for the field'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: %w[name value]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: %w[template_id submitters]
|
|
||||||
},
|
|
||||||
annotations: {
|
|
||||||
readOnlyHint: false,
|
|
||||||
destructiveHint: true,
|
|
||||||
idempotentHint: false,
|
|
||||||
openWorldHint: true
|
|
||||||
}
|
|
||||||
}.freeze
|
|
||||||
|
|
||||||
module_function
|
|
||||||
|
|
||||||
# rubocop:disable Metrics
|
|
||||||
def call(arguments, current_user, current_ability)
|
|
||||||
template = Template.accessible_by(current_ability).find_by(id: arguments['template_id'])
|
|
||||||
|
|
||||||
if !template || !current_ability.can?(:read, template)
|
|
||||||
return { content: [{ type: 'text', text: 'Template not found' }], isError: true }
|
|
||||||
end
|
|
||||||
|
|
||||||
if template.archived_at?
|
|
||||||
return { content: [{ type: 'text', text: 'Template has been archived' }], isError: true }
|
|
||||||
end
|
|
||||||
|
|
||||||
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|
|
|
||||||
attrs = s.slice('email', 'name', 'role', 'phone').compact_blank
|
|
||||||
|
|
||||||
fields = Array.wrap(s['fields']).filter_map do |f|
|
|
||||||
next if f['name'].blank?
|
|
||||||
|
|
||||||
{ 'name' => f['name'], 'default_value' => f['value'], 'readonly' => true }
|
|
||||||
end
|
|
||||||
|
|
||||||
attrs['fields'] = fields if fields.present?
|
|
||||||
|
|
||||||
attrs.with_indifferent_access
|
|
||||||
end
|
|
||||||
|
|
||||||
submissions = Submissions.create_from_submitters(
|
|
||||||
template:,
|
|
||||||
user: current_user,
|
|
||||||
source: :mcp,
|
|
||||||
submitters_order: template.preferences['submitters_order'].presence || 'random',
|
|
||||||
submissions_attrs: { 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)
|
|
||||||
|
|
||||||
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
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
Loading…
Reference in new issue