Skip to content

Commit 7ce6c29

Browse files
authored
Merge from docusealco/wip
2 parents 5fe75c8 + ae50b2e commit 7ce6c29

42 files changed

Lines changed: 729 additions & 636 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/docker.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ jobs:
3030
uses: docker/setup-buildx-action@v3
3131

3232
- name: Create .version file
33-
run: echo ${{ github.ref_name }} > .version
33+
env:
34+
REF_NAME: ${{ github.ref_name }}
35+
run: echo "$REF_NAME" > .version
3436

3537
- name: Login to Docker Hub
3638
uses: docker/login-action@v3

Gemfile.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ GEM
149149
crack (1.0.1)
150150
bigdecimal
151151
rexml
152-
crass (1.0.6)
152+
crass (1.0.7)
153153
csv (3.3.5)
154154
csv-safe (3.3.1)
155155
csv (~> 3.0)
@@ -293,7 +293,7 @@ GEM
293293
activesupport (>= 4)
294294
railties (>= 4)
295295
request_store (~> 1.0)
296-
loofah (2.25.1)
296+
loofah (2.25.2)
297297
crass (~> 1.0.2)
298298
nokogiri (>= 1.12.0)
299299
mail (2.9.0)
@@ -404,8 +404,8 @@ GEM
404404
activesupport (>= 5.0.0)
405405
minitest
406406
nokogiri (>= 1.6)
407-
rails-html-sanitizer (1.7.0)
408-
loofah (~> 2.25)
407+
rails-html-sanitizer (1.7.1)
408+
loofah (~> 2.25, >= 2.25.2)
409409
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
410410
rails-i18n (8.1.0)
411411
i18n (>= 0.7, < 2)

app/controllers/esign_settings_controller.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ def to_key
1414
prepend_before_action :maybe_redirect_com, only: %i[show]
1515

1616
before_action :load_encrypted_config
17-
authorize_resource :encrypted_config, parent: false, only: %i[new create]
18-
authorize_resource :encrypted_config, only: %i[update destroy show]
17+
authorize_resource :encrypted_config, parent: false
1918

2019
def show
2120
cert_data = @encrypted_config.value || {}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# frozen_string_literal: true
2+
3+
module Mcp
4+
class CreateTemplateController < McpBaseController
5+
SCHEMA = {
6+
name: 'create_template',
7+
title: 'Create Template',
8+
description: 'Create a document template. Provide a URL to upload a PDF/DOCX file, or provide only a name ' \
9+
'to create an empty template and receive an edit URL where the file can be uploaded via the UI.',
10+
inputSchema: {
11+
type: 'object',
12+
properties: {
13+
name: {
14+
type: 'string',
15+
description: 'Template name (used as the template name and required when url is not provided)'
16+
},
17+
url: {
18+
type: 'string',
19+
description: 'Optional URL of a PDF or DOCX file to upload. If omitted, an empty template is ' \
20+
'created and the returned edit_url can be used to upload a file via the UI.'
21+
}
22+
},
23+
required: %w[name]
24+
},
25+
annotations: {
26+
readOnlyHint: false,
27+
destructiveHint: false,
28+
idempotentHint: false,
29+
openWorldHint: true
30+
}
31+
}.freeze
32+
33+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
34+
def call
35+
account = current_user.account
36+
37+
@template = Template.new(
38+
account:,
39+
author: current_user,
40+
folder: account.default_template_folder,
41+
source: :mcp,
42+
name: mcp_params['name'].to_s.presence || 'New Template',
43+
fields: [],
44+
schema: []
45+
)
46+
47+
authorize!(:create, @template)
48+
49+
if mcp_params['url'].present?
50+
tempfile = Tempfile.new
51+
tempfile.binmode
52+
tempfile.write(DownloadUtils.call(mcp_params['url'], validate: true).body)
53+
tempfile.rewind
54+
55+
filename = File.basename(URI.decode_www_form_component(mcp_params['url']))
56+
57+
file = ActionDispatch::Http::UploadedFile.new(
58+
tempfile:,
59+
filename:,
60+
type: Marcel::MimeType.for(tempfile)
61+
)
62+
63+
@template.name = mcp_params['name'].presence || File.basename(filename, '.*')
64+
@template.save!
65+
66+
documents, = Templates::CreateAttachments.call(@template, { files: [file] }, extract_fields: true)
67+
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
68+
69+
if @template.fields.blank?
70+
@template.fields = Templates::ProcessDocument.normalize_attachment_fields(@template, documents)
71+
end
72+
73+
@template.update!(schema:)
74+
else
75+
@template.save!
76+
end
77+
78+
WebhookUrls.enqueue_events(@template, 'template.created')
79+
80+
SearchEntries.enqueue_reindex(@template)
81+
82+
render_tool_result(
83+
id: @template.id,
84+
name: @template.name,
85+
edit_url: edit_template_url(@template)
86+
)
87+
end
88+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
89+
end
90+
end
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# frozen_string_literal: true
2+
3+
module Mcp
4+
class LoadTemplateController < McpBaseController
5+
SCHEMA = {
6+
name: 'load_template',
7+
title: 'Load Template',
8+
description: 'Load a template with its fields. Each field includes name, type, and the signing role name.',
9+
inputSchema: {
10+
type: 'object',
11+
properties: {
12+
template_id: {
13+
type: 'integer',
14+
description: 'Template identifier'
15+
}
16+
},
17+
required: %w[template_id]
18+
},
19+
annotations: {
20+
readOnlyHint: true,
21+
destructiveHint: false,
22+
idempotentHint: true,
23+
openWorldHint: false
24+
}
25+
}.freeze
26+
27+
def call
28+
@template = Template.accessible_by(current_ability).find(mcp_params['template_id'])
29+
30+
authorize!(:read, @template)
31+
32+
submitters_index = @template.submitters.index_by { |s| s['uuid'] }
33+
34+
roles = @template.submitters.pluck('name')
35+
36+
fields = @template.fields.filter_map do |field|
37+
next if field['name'].blank?
38+
39+
{
40+
name: field['name'],
41+
type: field['type'],
42+
role: submitters_index[field['submitter_uuid']]&.dig('name')
43+
}
44+
end
45+
46+
render_tool_result(
47+
id: @template.id,
48+
name: @template.name,
49+
roles: roles,
50+
fields: fields
51+
)
52+
end
53+
end
54+
end
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# frozen_string_literal: true
2+
3+
module Mcp
4+
class McpBaseController < ActionController::API
5+
wrap_parameters false
6+
7+
before_action :authenticate_user!
8+
before_action :verify_mcp_enabled!
9+
check_authorization
10+
11+
before_action do
12+
raise CanCan::AccessDenied unless can?(:manage, :mcp)
13+
end
14+
15+
rescue_from CanCan::AccessDenied do
16+
render_error(-32_603, 'Forbidden', status: :forbidden)
17+
end
18+
19+
rescue_from ActiveRecord::RecordNotFound do
20+
render_tool_error('Not found')
21+
end
22+
23+
private
24+
25+
def default_url_options
26+
Docuseal.default_url_options
27+
end
28+
29+
def mcp_body
30+
request.request_parameters
31+
end
32+
33+
def mcp_params
34+
mcp_body.dig('params', 'arguments') || {}
35+
end
36+
37+
def render_result(result)
38+
render json: { jsonrpc: '2.0', id: mcp_body['id'], result: }
39+
end
40+
41+
def render_error(code, message, id: nil, status: :ok)
42+
render json: { jsonrpc: '2.0', id:, error: { code:, message: } }, status:
43+
end
44+
45+
def render_tool_result(data)
46+
render_result(content: [{ type: 'text', text: data.to_json }])
47+
end
48+
49+
def render_tool_error(message)
50+
render_result(content: [{ type: 'text', text: message }], isError: true)
51+
end
52+
53+
def authenticate_user!
54+
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
55+
end
56+
57+
def verify_mcp_enabled!
58+
return if Docuseal.multitenant?
59+
60+
return if AccountConfig.exists?(account_id: current_user.account_id,
61+
key: AccountConfig::ENABLE_MCP_KEY,
62+
value: true)
63+
64+
render json: { error: 'MCP is disabled' }, status: :forbidden
65+
end
66+
67+
def current_user
68+
@current_user ||= user_from_api_key
69+
end
70+
71+
def user_from_api_key
72+
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
73+
74+
return if token.blank?
75+
76+
sha256 = Digest::SHA256.hexdigest(token)
77+
78+
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
79+
end
80+
end
81+
end
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
module Mcp
4+
class ProtocolController < McpBaseController
5+
skip_authorization_check
6+
7+
def ok
8+
head :ok
9+
end
10+
11+
def initialize_request
12+
render_result(
13+
protocolVersion: '2025-11-25',
14+
serverInfo: {
15+
name: 'DocuSeal',
16+
version: Docuseal.version.to_s
17+
},
18+
capabilities: {
19+
tools: {
20+
listChanged: false
21+
}
22+
}
23+
)
24+
end
25+
26+
def initialized_notification
27+
head :accepted
28+
end
29+
30+
def ping
31+
render_result({})
32+
end
33+
34+
def tools_list
35+
render_result(tools: McpController::TOOLS)
36+
end
37+
38+
def method_not_found
39+
render_error(-32_601, "Method not found: #{mcp_body['method']}", id: mcp_body['id'])
40+
end
41+
42+
def tool_not_found
43+
render_error(-32_602, "Unknown tool: #{mcp_body.dig('params', 'name')}", id: mcp_body['id'])
44+
end
45+
46+
def parse_error
47+
render_error(-32_700, 'Parse error', status: :bad_request)
48+
end
49+
end
50+
end
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# frozen_string_literal: true
2+
3+
module Mcp
4+
class SearchDocumentsController < McpBaseController
5+
SCHEMA = {
6+
name: 'search_documents',
7+
title: 'Search Documents',
8+
description: 'Search signed or pending documents by submitter name, email, phone, or template name',
9+
inputSchema: {
10+
type: 'object',
11+
properties: {
12+
q: {
13+
type: 'string',
14+
description: 'Search by submitter name, email, phone, or template name'
15+
},
16+
limit: {
17+
type: 'integer',
18+
description: 'The number of results to return (default 10)'
19+
}
20+
},
21+
required: %w[q]
22+
},
23+
annotations: {
24+
readOnlyHint: true,
25+
destructiveHint: false,
26+
idempotentHint: true,
27+
openWorldHint: false
28+
}
29+
}.freeze
30+
31+
def call
32+
authorize!(:read, Submission)
33+
34+
submissions = Submissions.search(current_user, Submission.accessible_by(current_ability).active,
35+
mcp_params['q'], search_template: true)
36+
37+
limit = mcp_params.fetch('limit', 10).to_i
38+
limit = 10 if limit <= 0
39+
limit = [limit, 100].min
40+
submissions = submissions.preload(:submitters, :template)
41+
.order(id: :desc)
42+
.limit(limit)
43+
44+
data = submissions.map do |submission|
45+
{
46+
id: submission.id,
47+
template_name: submission.template&.name,
48+
status: Submissions::SerializeForApi.build_status(submission, submission.submitters),
49+
submitters: submission.submitters.map do |s|
50+
{ email: s.email, name: s.name, phone: s.phone, status: s.status }
51+
end,
52+
documents_url: submission_url(submission.id)
53+
}
54+
end
55+
56+
render_tool_result(data)
57+
end
58+
end
59+
end

0 commit comments

Comments
 (0)