add MCP support

This commit is contained in:
Alex Turchyn
2026-03-05 12:17:14 +02:00
committed by GitHub
parent 961f09e092
commit 62a969d8fe
20 changed files with 820 additions and 2 deletions
+3
View File
@@ -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
+65
View File
@@ -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
+110
View File
@@ -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
+65
View File
@@ -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
+53
View File
@@ -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
+114
View File
@@ -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