add MCP support
This commit is contained in:
@@ -20,6 +20,9 @@ class Ability
|
||||
can :manage, UserConfig, user_id: user.id
|
||||
can :manage, Account, id: user.account_id
|
||||
can :manage, AccessToken, user_id: user.id
|
||||
can :manage, McpToken, user_id: user.id
|
||||
can :manage, WebhookUrl, account_id: user.account_id
|
||||
|
||||
can :manage, :mcp
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
module HandleRequest
|
||||
TOOLS = [
|
||||
Mcp::Tools::SearchTemplates,
|
||||
Mcp::Tools::CreateTemplate,
|
||||
Mcp::Tools::SendDocuments,
|
||||
Mcp::Tools::SearchDocuments
|
||||
].freeze
|
||||
|
||||
TOOLS_SCHEMA = TOOLS.map { |t| t::SCHEMA }
|
||||
|
||||
TOOLS_INDEX = TOOLS.index_by { |t| t::SCHEMA[:name] }
|
||||
|
||||
module_function
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def call(body, current_user, current_ability)
|
||||
case body['method']
|
||||
when 'initialize'
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: body['id'],
|
||||
result: {
|
||||
protocolVersion: '2025-11-25',
|
||||
serverInfo: {
|
||||
name: 'DocuSeal',
|
||||
version: Docuseal.version.to_s
|
||||
},
|
||||
capabilities: {
|
||||
tools: {
|
||||
listChanged: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
when 'notifications/initialized'
|
||||
nil
|
||||
when 'ping'
|
||||
{ jsonrpc: '2.0', id: body['id'], result: {} }
|
||||
when 'tools/list'
|
||||
{ jsonrpc: '2.0', id: body['id'], result: { tools: TOOLS_SCHEMA } }
|
||||
when 'tools/call'
|
||||
tool = TOOLS_INDEX[body.dig('params', 'name')]
|
||||
|
||||
raise "Unknown tool: #{body.dig('params', 'name')}" unless tool
|
||||
|
||||
result = tool.call(body.dig('params', 'arguments') || {}, current_user, current_ability)
|
||||
|
||||
{ jsonrpc: '2.0', id: body['id'], result: }
|
||||
else
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: body['id'],
|
||||
error: {
|
||||
code: -32_601,
|
||||
message: "Method not found: #{body['method']}"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,110 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
module Tools
|
||||
module CreateTemplate
|
||||
SCHEMA = {
|
||||
name: 'create_template',
|
||||
title: 'Create Template',
|
||||
description: 'Create a template from a PDF. Provide a URL or base64-encoded file content.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'URL of the document file to upload'
|
||||
},
|
||||
file: {
|
||||
type: 'string',
|
||||
description: 'Base64-encoded file content'
|
||||
},
|
||||
filename: {
|
||||
type: 'string',
|
||||
description: 'Filename with extension (required when using file)'
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Template name (defaults to filename)'
|
||||
}
|
||||
}
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false
|
||||
}
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
||||
def call(arguments, current_user, current_ability)
|
||||
current_ability.authorize!(:create, Template.new(account_id: current_user.account_id, author: current_user))
|
||||
|
||||
account = current_user.account
|
||||
|
||||
if arguments['file'].present?
|
||||
tempfile = Tempfile.new
|
||||
tempfile.binmode
|
||||
tempfile.write(Base64.decode64(arguments['file']))
|
||||
tempfile.rewind
|
||||
|
||||
filename = arguments['filename'] || 'document.pdf'
|
||||
elsif arguments['url'].present?
|
||||
tempfile = Tempfile.new
|
||||
tempfile.binmode
|
||||
tempfile.write(DownloadUtils.call(arguments['url'], validate: true).body)
|
||||
tempfile.rewind
|
||||
|
||||
filename = File.basename(URI.decode_www_form_component(arguments['url']))
|
||||
else
|
||||
return { content: [{ type: 'text', text: 'Provide either url or file' }], isError: true }
|
||||
end
|
||||
|
||||
file = ActionDispatch::Http::UploadedFile.new(
|
||||
tempfile:,
|
||||
filename:,
|
||||
type: Marcel::MimeType.for(tempfile)
|
||||
)
|
||||
|
||||
template = Template.new(
|
||||
account:,
|
||||
author: current_user,
|
||||
folder: account.default_template_folder,
|
||||
name: arguments['name'].presence || File.basename(filename, '.*')
|
||||
)
|
||||
|
||||
template.save!
|
||||
|
||||
documents, = Templates::CreateAttachments.call(template, { files: [file] }, extract_fields: true)
|
||||
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
|
||||
|
||||
if template.fields.blank?
|
||||
template.fields = Templates::ProcessDocument.normalize_attachment_fields(template, documents)
|
||||
end
|
||||
|
||||
template.update!(schema:)
|
||||
|
||||
WebhookUrls.enqueue_events(template, 'template.created')
|
||||
|
||||
SearchEntries.enqueue_reindex(template)
|
||||
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
edit_url: Rails.application.routes.url_helpers.edit_template_url(template,
|
||||
**Docuseal.default_url_options)
|
||||
}.to_json
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
module Tools
|
||||
module SearchDocuments
|
||||
SCHEMA = {
|
||||
name: 'search_documents',
|
||||
title: 'Search Documents',
|
||||
description: 'Search signed or pending documents by submitter name, email, phone, or template name',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: {
|
||||
type: 'string',
|
||||
description: 'Search by submitter name, email, phone, or template name'
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
description: 'The number of results to return (default 10)'
|
||||
}
|
||||
},
|
||||
required: %w[q]
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def call(arguments, current_user, current_ability)
|
||||
submissions = Submissions.search(current_user, Submission.accessible_by(current_ability).active,
|
||||
arguments['q'], search_template: true)
|
||||
|
||||
limit = arguments.fetch('limit', 10).to_i
|
||||
limit = 10 if limit <= 0
|
||||
limit = [limit, 100].min
|
||||
submissions = submissions.preload(:submitters, :template)
|
||||
.order(id: :desc)
|
||||
.limit(limit)
|
||||
|
||||
data = submissions.map do |submission|
|
||||
url = Rails.application.routes.url_helpers.submission_url(
|
||||
submission.id, **Docuseal.default_url_options
|
||||
)
|
||||
|
||||
{
|
||||
id: submission.id,
|
||||
template_name: submission.template&.name,
|
||||
status: Submissions::SerializeForApi.build_status(submission, submission.submitters),
|
||||
submitters: submission.submitters.map do |s|
|
||||
{ email: s.email, name: s.name, phone: s.phone, status: s.status }
|
||||
end,
|
||||
documents_url: url
|
||||
}
|
||||
end
|
||||
|
||||
{ content: [{ type: 'text', text: data.to_json }] }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
module Tools
|
||||
module SearchTemplates
|
||||
SCHEMA = {
|
||||
name: 'search_templates',
|
||||
title: 'Search Templates',
|
||||
description: 'Search document templates by name',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: {
|
||||
type: 'string',
|
||||
description: 'Search query to filter templates by name'
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
description: 'The number of templates to return (default 10)'
|
||||
}
|
||||
},
|
||||
required: %w[q]
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def call(arguments, current_user, current_ability)
|
||||
templates = Templates.search(current_user, Template.accessible_by(current_ability).active, arguments['q'])
|
||||
|
||||
limit = arguments.fetch('limit', 10).to_i
|
||||
limit = 10 if limit <= 0
|
||||
limit = [limit, 100].min
|
||||
templates = templates.order(id: :desc).limit(limit)
|
||||
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: templates.map { |t| { id: t.id, name: t.name } }.to_json
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,114 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
module Tools
|
||||
module SendDocuments
|
||||
SCHEMA = {
|
||||
name: 'send_documents',
|
||||
title: 'Send Documents',
|
||||
description: 'Send a document template for signing to specified submitters',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
template_id: {
|
||||
type: 'integer',
|
||||
description: 'Template identifier'
|
||||
},
|
||||
submitters: {
|
||||
type: 'array',
|
||||
description: 'The list of submitters (signers)',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
email: {
|
||||
type: 'string',
|
||||
description: 'Submitter email address'
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Submitter name'
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
description: 'Submitter phone number in E.164 format'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
required: %w[template_id submitters]
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true
|
||||
}
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def call(arguments, current_user, current_ability)
|
||||
template = Template.accessible_by(current_ability).find_by(id: arguments['template_id'])
|
||||
|
||||
return { content: [{ type: 'text', text: 'Template not found' }], isError: true } unless template
|
||||
|
||||
current_ability.authorize!(:create, Submission.new(template:, account_id: current_user.account_id))
|
||||
|
||||
return { content: [{ type: 'text', text: 'Template has no fields' }], isError: true } if template.fields.blank?
|
||||
|
||||
submitters = (arguments['submitters'] || []).map do |s|
|
||||
s.slice('email', 'name', 'role', 'phone')
|
||||
.compact_blank
|
||||
.with_indifferent_access
|
||||
end
|
||||
|
||||
submissions = Submissions.create_from_submitters(
|
||||
template:,
|
||||
user: current_user,
|
||||
source: :api,
|
||||
submitters_order: 'random',
|
||||
submissions_attrs: { submitters: submitters },
|
||||
params: { 'send_email' => true, 'submitters' => submitters }
|
||||
)
|
||||
|
||||
if submissions.blank?
|
||||
return { content: [{ type: 'text', text: 'No valid submitters provided' }], isError: true }
|
||||
end
|
||||
|
||||
WebhookUrls.enqueue_events(submissions, 'submission.created')
|
||||
|
||||
Submissions.send_signature_requests(submissions)
|
||||
|
||||
submissions.each do |submission|
|
||||
submission.submitters.each do |submitter|
|
||||
next unless submitter.completed_at?
|
||||
|
||||
ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id,
|
||||
'send_invitation_email' => false)
|
||||
end
|
||||
end
|
||||
|
||||
SearchEntries.enqueue_reindex(submissions)
|
||||
|
||||
submission = submissions.first
|
||||
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: {
|
||||
id: submission.id,
|
||||
status: 'pending'
|
||||
}.to_json
|
||||
}
|
||||
]
|
||||
}
|
||||
rescue Submissions::CreateFromSubmitters::BaseError => e
|
||||
{ content: [{ type: 'text', text: e.message }], isError: true }
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user