diff --git a/app/controllers/mcp/create_template_controller.rb b/app/controllers/mcp/create_template_controller.rb new file mode 100644 index 00000000..b4c06a63 --- /dev/null +++ b/app/controllers/mcp/create_template_controller.rb @@ -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 diff --git a/app/controllers/mcp/load_template_controller.rb b/app/controllers/mcp/load_template_controller.rb new file mode 100644 index 00000000..cefd688a --- /dev/null +++ b/app/controllers/mcp/load_template_controller.rb @@ -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 diff --git a/app/controllers/mcp/mcp_base_controller.rb b/app/controllers/mcp/mcp_base_controller.rb new file mode 100644 index 00000000..996c350c --- /dev/null +++ b/app/controllers/mcp/mcp_base_controller.rb @@ -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 diff --git a/app/controllers/mcp/protocol_controller.rb b/app/controllers/mcp/protocol_controller.rb new file mode 100644 index 00000000..0cf07584 --- /dev/null +++ b/app/controllers/mcp/protocol_controller.rb @@ -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 diff --git a/app/controllers/mcp/search_documents_controller.rb b/app/controllers/mcp/search_documents_controller.rb new file mode 100644 index 00000000..f58c0e17 --- /dev/null +++ b/app/controllers/mcp/search_documents_controller.rb @@ -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 diff --git a/app/controllers/mcp/search_templates_controller.rb b/app/controllers/mcp/search_templates_controller.rb new file mode 100644 index 00000000..bc630b35 --- /dev/null +++ b/app/controllers/mcp/search_templates_controller.rb @@ -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 diff --git a/app/controllers/mcp/send_documents_controller.rb b/app/controllers/mcp/send_documents_controller.rb new file mode 100644 index 00000000..7fadc8a1 --- /dev/null +++ b/app/controllers/mcp/send_documents_controller.rb @@ -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 diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index 8d7ca288..7f0db5e8 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -1,58 +1,44 @@ # frozen_string_literal: true -class McpController < ActionController::API - before_action :authenticate_user! - before_action :verify_mcp_enabled! +class McpController < ActionController::Metal + TOOL_CONTROLLERS = { + 'search_templates' => Mcp::SearchTemplatesController, + 'load_template' => Mcp::LoadTemplateController, + 'create_template' => Mcp::CreateTemplateController, + 'send_documents' => Mcp::SendDocumentsController, + 'search_documents' => Mcp::SearchDocumentsController + }.freeze - before_action do - authorize!(:manage, :mcp) - end + TOOLS = TOOL_CONTROLLERS.map { |_, controller| controller::SCHEMA }.freeze 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 = nil unless body.is_a?(Hash) - result = Mcp::HandleRequest.call(body, current_user, current_ability) + request.request_parameters = body || {} - 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 + action = + case body&.dig('method') + when 'initialize' then :initialize_request + when 'notifications/initialized' then :initialized_notification + when 'ping' then :ping + when 'tools/list' then :tools_list + when 'tools/call' + tool = TOOL_CONTROLLERS[body.dig('params', 'name')] - private + return tool.dispatch(:call, request, response) if tool - def authenticate_user! - render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user - end + :tool_not_found + else + :method_not_found + 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) + Mcp::ProtocolController.dispatch(action, request, response) + rescue JSON::ParserError + request.request_parameters = {} - User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil }) + Mcp::ProtocolController.dispatch(:parse_error, request, response) end end diff --git a/lib/mcp/handle_request.rb b/lib/mcp/handle_request.rb deleted file mode 100644 index 83d6725f..00000000 --- a/lib/mcp/handle_request.rb +++ /dev/null @@ -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 diff --git a/lib/mcp/tools/create_template.rb b/lib/mcp/tools/create_template.rb deleted file mode 100644 index f19b353e..00000000 --- a/lib/mcp/tools/create_template.rb +++ /dev/null @@ -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 diff --git a/lib/mcp/tools/load_template.rb b/lib/mcp/tools/load_template.rb deleted file mode 100644 index ee52af37..00000000 --- a/lib/mcp/tools/load_template.rb +++ /dev/null @@ -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 diff --git a/lib/mcp/tools/search_documents.rb b/lib/mcp/tools/search_documents.rb deleted file mode 100644 index bf1f56f5..00000000 --- a/lib/mcp/tools/search_documents.rb +++ /dev/null @@ -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 diff --git a/lib/mcp/tools/search_templates.rb b/lib/mcp/tools/search_templates.rb deleted file mode 100644 index 9d9bbe52..00000000 --- a/lib/mcp/tools/search_templates.rb +++ /dev/null @@ -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 diff --git a/lib/mcp/tools/send_documents.rb b/lib/mcp/tools/send_documents.rb deleted file mode 100644 index 04894798..00000000 --- a/lib/mcp/tools/send_documents.rb +++ /dev/null @@ -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