siglio-mcp 1.0.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +44 -6
  2. package/index.js +224 -38
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -10,7 +10,7 @@ It runs on your own machine, so your API key never leaves it.
10
10
  Get a key from [your Siglio account](https://esigndev.com/app). Signing up takes
11
11
  an email address, no card, and comes with 25 free envelopes.
12
12
 
13
- **Claude Desktop** — add this to your config file:
13
+ **Claude Desktop:** add this to your config file:
14
14
 
15
15
  ```json
16
16
  {
@@ -30,14 +30,29 @@ Other MCP clients take the same command and environment.
30
30
 
31
31
  | Tool | What it does |
32
32
  |---|---|
33
- | `send_for_signature` | Sends a PDF to one or two people, by email, text, or both |
34
- | `check_envelope` | Whether it has been delivered, opened, signed or cancelled |
33
+ | `send_for_signature` | Sends a PDF to one or two people, by email, text, or both. Can also ask for a payment, files, or answers |
34
+ | `check_envelope` | Whether it has been delivered, opened, signed or cancelled, plus any payment, files and answers |
35
35
  | `download_signed_document` | Saves the completed PDF once every signature is in |
36
+ | `download_attachment` | Saves a file the signer uploaded, such as a photo ID |
36
37
  | `void_envelope` | Cancels an unsigned envelope; the link stops working |
37
38
 
38
39
  Then you can just ask: *"Send the NDA on my desktop to Dana at 813 555 0142 by
39
40
  text."*
40
41
 
42
+ When the last signer signs, every signer is sent the signed PDF automatically.
43
+
44
+ ## Options on a send
45
+
46
+ All optional. Leave them out and you get a plain signature request.
47
+
48
+ | Input | What it does |
49
+ |---|---|
50
+ | `delivery` | `auto` (the default) sends by email and text together when the signer has both, otherwise by whichever one they have. `email`, `sms` and `both` are strict |
51
+ | `payment` | Asks the signer to pay, in whole cents: `{ "amount": 15000, "description": "Deposit", "when": "after" }`. Paid straight into your own Stripe account, which you connect once in the document studio (choose Connect Stripe). `before` holds the document until it is paid |
52
+ | `attachments` | Asks the signer to upload 1 to 5 files from their phone: `{ "when": "before", "items": [{ "label": "Photo ID" }] }`. `before` holds the document until the files are in |
53
+ | `form_fields` | **Enterprise.** Labels, types, options, formats and show or require rules for the answer fields in your PDF |
54
+ | `constraints` | **Enterprise.** Rules across answer fields: exactly one, at least N, at most N, one of, requires |
55
+
41
56
  ## The one thing to know about your PDF
42
57
 
43
58
  Siglio does not take coordinates for signature fields. Placement comes from
@@ -55,6 +70,29 @@ instructions, not content, and black ones stay visible on the signed document.
55
70
  If the tags are missing the send is rejected and nothing goes out, so you cannot
56
71
  accidentally mail someone a document they have no way to sign.
57
72
 
73
+ ## Collecting answers (Enterprise)
74
+
75
+ On Enterprise accounts the PDF can also ask the signer questions, which they fill
76
+ in on a short form before signing:
77
+
78
+ ```
79
+ ^M1 required text ^T1 optional text
80
+ ^C1 a checkbox ^R1_G1 one option of pick-one group 1
81
+ ```
82
+
83
+ Each can carry a name, and boxes a value: `^M1:policy_number`,
84
+ `^C1:coverage=liability`, `^R1_G1:plan=monthly`. `check_envelope` then shows the
85
+ answers by their labels. Social Security numbers are always shown as the last
86
+ four digits only.
87
+
88
+ On other accounts these tags are refused and nothing is sent.
89
+ [Contact Siglio](https://esigndev.com/contact?topic=enterprise) to turn it on.
90
+
91
+ ## Keep your own copy
92
+
93
+ Signed PDFs, uploaded files and answers are deleted 30 days after the envelope
94
+ closes. Download what you need before then.
95
+
58
96
  ## Two safety decisions worth knowing
59
97
 
60
98
  **Live keys are refused by default.** A key starting with `sig_live_` will not
@@ -63,8 +101,8 @@ confused assistant can do is send a free sandbox envelope. Sandbox envelopes
63
101
  deliver for real and produce real signatures; they just cost nothing.
64
102
 
65
103
  **Sending is idempotent by content.** If an assistant retries the same document
66
- to the same people, it gets the first envelope back instead of putting a second
67
- copy of a contract in someone's inbox.
104
+ to the same people with the same options, it gets the first envelope back
105
+ instead of putting a second copy of a contract in someone's inbox.
68
106
 
69
107
  Cancelling additionally requires an explicit confirmation, so it cannot happen as
70
108
  a side effect of a vague instruction.
@@ -81,4 +119,4 @@ a side effect of a vague instruction.
81
119
 
82
120
  - Full API reference: <https://esigndev.com/llms-full.txt>
83
121
  - OpenAPI spec: <https://esigndev.com/openapi.json>
84
- - Docs: <https://esigndev.com/docs>
122
+ - Docs: <https://esigndev.com/docs>
package/index.js CHANGED
@@ -2,8 +2,8 @@
2
2
  // Siglio MCP server.
3
3
  //
4
4
  // Lets an AI assistant send a document for signature, check on it, fetch the
5
- // signed file, and cancel one. It runs on the user's own machine, so their API
6
- // key never leaves it.
5
+ // signed file and any files the signer uploaded, and cancel one. It runs on the
6
+ // user's own machine, so their API key never leaves it.
7
7
  //
8
8
  // Two deliberate safety positions, because these tools send legally binding
9
9
  // documents to real people:
@@ -11,13 +11,17 @@
11
11
  // 1. LIVE KEYS ARE REFUSED unless SIGLIO_ALLOW_LIVE=true is set explicitly.
12
12
  // The default blast radius of a confused model is a sandbox envelope.
13
13
  // 2. Sending is IDEMPOTENT BY CONTENT. If no key is supplied we derive one
14
- // from the document and the signers, so an assistant that retries the
15
- // same call does not put a second copy of a contract in someone's inbox.
16
- // Models retry. This makes that harmless instead of embarrassing.
14
+ // from the document, the signers and every option sent, so an assistant
15
+ // that retries the same call does not put a second copy of a contract in
16
+ // someone's inbox. Models retry. This makes that harmless.
17
17
  //
18
18
  // Voiding additionally requires confirm:true, so it cannot happen as a casual
19
19
  // side effect of a vague instruction.
20
20
  //
21
+ // Answers collected from signers are personal data. This server prints them
22
+ // for the user who owns the key and never writes them anywhere else. Social
23
+ // Security numbers are always masked to the last four digits.
24
+ //
21
25
  // Environment:
22
26
  // SIGLIO_API_KEY required. sig_sandbox_... or sig_live_...
23
27
  // SIGLIO_ALLOW_LIVE set to "true" to permit a live key
@@ -30,6 +34,7 @@ import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
30
34
  import { basename, resolve } from 'node:path';
31
35
  import { createHash } from 'node:crypto';
32
36
 
37
+ const VERSION = '1.1.0';
33
38
  const API_BASE = process.env.SIGLIO_API_BASE || 'https://api.esigndev.com';
34
39
  const KEY = (process.env.SIGLIO_API_KEY || '').trim();
35
40
  const INLINE_LIMIT = 3 * 1024 * 1024; // the API's inline document ceiling
@@ -54,7 +59,7 @@ const IS_LIVE = /^sig_live_/.test(KEY);
54
59
  // --- talking to the API --------------------------------------------------
55
60
 
56
61
  async function api(method, path, { body, idempotencyKey, raw } = {}) {
57
- const headers = { Authorization: 'Bearer ' + KEY, 'User-Agent': 'siglio-mcp/1.0' };
62
+ const headers = { Authorization: 'Bearer ' + KEY, 'User-Agent': 'siglio-mcp/' + VERSION };
58
63
  if (body) headers['Content-Type'] = 'application/json';
59
64
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
60
65
 
@@ -74,33 +79,78 @@ async function api(method, path, { body, idempotencyKey, raw } = {}) {
74
79
  throw new Error(explain(res.status, parsed, res.headers.get('retry-after')));
75
80
  }
76
81
 
82
+ const KEEP_A_COPY = 'Signed PDFs, uploaded files and answers are deleted 30 days after the envelope ' +
83
+ 'closes, so the sender should keep their own copy.';
84
+
77
85
  // Turn an API error into something an assistant can act on rather than repeat.
86
+ // The specific reason is in error.code; error.type is the broad category
87
+ // (feature_not_enabled, for example, arrives as type authorization_error).
78
88
  function explain(status, parsed, retryAfter) {
79
89
  const e = (parsed && parsed.error) || {};
80
90
  const rid = e.request_id ? ' (request_id ' + e.request_id + ')' : '';
81
- switch (e.type) {
91
+ const msg = e.message ? ' ' + e.message : '';
92
+ switch (e.code || e.type) {
82
93
  case 'missing_required_tag':
94
+ case 'missing_required_signature_tag':
95
+ case 'unsupported_signer_tag':
83
96
  return 'The PDF does not carry the signature tags the request needs' + rid + '. ' +
84
97
  'Placement comes from literal text inside the document: put ^S1 where signer one signs, ' +
85
98
  '^I1 for initials, ^D1 for a self-filling date, and ^S2 / ^I2 / ^D2 for a second signer. ' +
86
99
  'A document tagged for two signers must be sent with two, and one tagged for one with one. ' +
87
100
  'Set that tag text to WHITE so it does not print on the finished document. ' +
88
- 'Siglio message: ' + (e.message || '');
101
+ 'Siglio message:' + msg;
89
102
  case 'invalid_document':
90
- return 'Siglio could not use that PDF' + rid + ': ' + (e.message || '') +
103
+ case 'unreadable_pdf':
104
+ return 'Siglio could not use that PDF' + rid + ':' + msg +
91
105
  ' Check it opens normally and is a real PDF rather than a renamed file.';
106
+ case 'file_too_large':
107
+ return 'That PDF is too large for this server' + rid + '.' + msg +
108
+ ' Compress it, or use the upload endpoint directly (see https://esigndev.com/llms-full.txt).';
109
+ case 'feature_not_enabled':
110
+ return 'Collecting answers from the signer (^M, ^T, ^C or ^R tags, form_fields or constraints) is an ' +
111
+ 'Enterprise feature that is not turned on for this account' + rid + '. Nothing was sent and nothing ' +
112
+ 'was billed. Tell the user to contact Siglio at https://esigndev.com/contact?topic=enterprise, or ' +
113
+ 'remove those tags and fields and send again. Do not retry as is.';
114
+ case 'invalid_form_fields':
115
+ return 'The form_fields do not fit the document' + rid + '.' + msg +
116
+ ' Fix the entry the message names and send again.';
117
+ case 'invalid_constraints':
118
+ return 'The constraints do not fit the document' + rid + '.' + msg +
119
+ ' Fix the entry the message names and send again.';
120
+ case 'payment_not_configured':
121
+ return 'This account cannot take payments yet' + rid + '. Connect a Stripe account in the Siglio ' +
122
+ 'document studio first (choose Connect Stripe), then send again. Nothing was sent.';
123
+ case 'invalid_signer':
124
+ return 'Siglio rejected a signer' + rid + ':' + msg + ' Check the name, email and phone.';
125
+ case 'document_deleted':
126
+ return 'That file was deleted after 30 days, the sender should keep their own copy' + rid + '. Siglio ' +
127
+ 'deletes signed PDFs, uploaded files and answers 30 days after the envelope closes. It cannot be recovered.';
128
+ case 'not_found':
129
+ return 'Siglio has no envelope or file with that id for this key' + rid + '. Envelope ids come from the ' +
130
+ 'response to send_for_signature and look like env_7mmyc.... Check the id for a typo; retrying the ' +
131
+ 'same id will not help. A sandbox key cannot see live envelopes, and the reverse.';
92
132
  case 'authentication_error':
93
133
  return 'The API key was rejected' + rid + '. Get a current key from https://esigndev.com/app.';
134
+ case 'authorization_error':
135
+ return 'This key is not allowed to do that' + rid + '.' + msg;
94
136
  case 'payment_required':
95
137
  return 'This account needs a card on file before it can send' + rid +
96
138
  '. Add one at https://esigndev.com/app, or use a sandbox key, which is free.';
97
139
  case 'usage_limit_reached':
98
140
  return 'This account is out of its free allowance' + rid +
99
141
  '. Add a card at https://esigndev.com/app to keep sending.';
142
+ case 'self_imposed_cap_reached':
143
+ return 'This account has hit the spending cap its owner set' + rid +
144
+ '. Raise or remove it at https://esigndev.com/app.';
145
+ case 'new_envelopes_paused':
146
+ case 'account_paused':
147
+ return 'Sending is paused on this account' + rid + '.' + msg;
100
148
  case 'rate_limited':
101
149
  return 'Rate limited' + rid + '. Wait ' + (retryAfter || 'a few') + ' seconds and retry with the same idempotency key.';
102
150
  case 'service_unavailable':
103
- return 'Siglio is temporarily unavailable' + rid + '. Retrying with the same idempotency key is safe.';
151
+ return 'Siglio is temporarily unavailable' + rid + '.' + msg + ' Retrying with the same idempotency key is safe.';
152
+ case 'invalid_request':
153
+ return 'Siglio rejected the request' + rid + ':' + msg;
104
154
  default:
105
155
  return 'Siglio returned ' + status + rid + ': ' + (e.message || JSON.stringify(parsed) || 'no detail');
106
156
  }
@@ -123,6 +173,9 @@ function loadPdf(p) {
123
173
  return { base64: buf.toString('base64'), bytes: buf.length, name: basename(full) };
124
174
  }
125
175
 
176
+ const dollars = (cents) => '$' + (cents / 100).toFixed(2);
177
+ const size = (b) => b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(b / 1024)) + ' KB';
178
+
126
179
  function describe(env) {
127
180
  const lines = [
128
181
  'Envelope ' + env.id,
@@ -133,29 +186,94 @@ function describe(env) {
133
186
  ];
134
187
  const people = env.signers || (env.signer ? [env.signer] : []);
135
188
  const done = env.state === 'completed';
189
+ const partial = env.state === 'partially_signed';
136
190
  people.forEach((s, i) => {
137
- // On a completed envelope every signer has signed, whether or not the time
138
- // was recorded. signed_at null means "no recorded time", never "unsigned",
139
- // so never say "not yet signed" about a finished document.
140
- const partial = env.state === 'partially_signed';
141
- const status = s.signed_at ? ' — signed ' + s.signed_at
142
- : done ? ' — signed, time not recorded'
143
- : partial ? ' — signed, time not yet recorded'
144
- : ' — not yet signed';
191
+ // signed_at null means "no recorded time", never "unsigned" on a completed
192
+ // envelope. On partially_signed, signer one has signed and signer two has
193
+ // not, whatever the timestamps say.
194
+ const signed = !!s.signed_at || done || (partial && i === 0);
195
+ const status = s.signed_at ? 'signed ' + s.signed_at
196
+ : signed ? (done ? 'signed, time not recorded' : 'signed, time not yet recorded')
197
+ : 'not yet signed';
145
198
  lines.push('Signer ' + (i + 1) + ': ' + s.name +
146
- [s.email, s.phone].filter(Boolean).map((x) => ' <' + x + '>').join('') + status);
199
+ [s.email, s.phone].filter(Boolean).map((x) => ' <' + x + '>').join('') +
200
+ (s.delivery ? ' via ' + s.delivery : '') + ', ' + status);
147
201
  if (s.signing_url) lines.push(' link: ' + s.signing_url);
148
202
  });
149
203
  if (!people.length && env.signing_url) lines.push('Signing link: ' + env.signing_url);
204
+
205
+ const p = env.payment;
206
+ if (p) {
207
+ lines.push('Payment: ' + dollars(p.amount) + (p.description ? ' for ' + p.description : '') +
208
+ ', ' + (p.when === 'before' ? 'before signing' : 'after signing') + ', ' + p.status +
209
+ (p.paid_at ? ' on ' + p.paid_at : ''));
210
+ if (p.pay_url) lines.push(' pay link: ' + p.pay_url);
211
+ if (p.when === 'before' && p.status === 'pending')
212
+ lines.push(' The document is held until this is paid; the signing link is the payment page for now.');
213
+ }
214
+
215
+ const a = env.attachments;
216
+ if (a) {
217
+ lines.push('Files from the signer (' + (a.when === 'before' ? 'before signing' : 'after signing') + '): ' +
218
+ a.status + (a.received_at ? ' on ' + a.received_at : ''));
219
+ for (const it of a.items || []) {
220
+ const files = it.files || [];
221
+ lines.push(' ' + it.label + (it.required === false ? ' (optional)' : '') + ': ' +
222
+ (files.length ? files.map((f) => f.name + ' (' + size(f.size) + ', id ' + f.id + ')').join('; ') : 'nothing yet'));
223
+ }
224
+ if (a.upload_url) lines.push(' upload link: ' + a.upload_url);
225
+ if (a.when === 'before' && a.status === 'pending')
226
+ lines.push(' The document is held until the required files are in.');
227
+ if ((a.items || []).some((it) => (it.files || []).length))
228
+ lines.push(' Save one with download_attachment and its id.');
229
+ }
230
+
231
+ const answers = describeAnswers(env);
232
+ if (answers.length) lines.push(...answers);
233
+
150
234
  if (env.completed_at) lines.push('Completed: ' + env.completed_at);
151
235
  return lines.join('\n');
152
236
  }
153
237
 
238
+ // One line per signer: "Answers (signer 1): Policy number: A-1234; I agree: yes".
239
+ // A choice shows its option label, not the stored value.
240
+ function describeAnswers(env) {
241
+ const values = env.field_values;
242
+ if (!Array.isArray(values) || !values.length) return [];
243
+ const defs = new Map(((env.form && env.form.fields) || []).map((f) => [f.name, f]));
244
+ const optionLabel = (name, v) => {
245
+ const o = ((defs.get(name) || {}).options || []).find((x) => x.value === v);
246
+ return o && o.label ? o.label : v;
247
+ };
248
+ const render = (fv) => {
249
+ if (fv.deleted) return 'deleted after 30 days';
250
+ if (fv.value === null || fv.value === undefined) return fv.submitted_at ? 'left blank' : 'not signed yet';
251
+ if (fv.type === 'checkbox') return fv.value === true ? 'yes' : 'no';
252
+ if (fv.type === 'checkbox_group')
253
+ return Array.isArray(fv.value) && fv.value.length ? fv.value.map((v) => optionLabel(fv.name, v)).join(', ') : 'none';
254
+ if (fv.type === 'select' || fv.type === 'radio') return optionLabel(fv.name, fv.value);
255
+ // Social Security numbers never appear in full: chat transcripts are kept.
256
+ const fmt = fv.format || (defs.get(fv.name) || {}).format;
257
+ if (fmt === 'ssn') { const d = String(fv.value).replace(/\D/g, ''); return '***-**-' + (d.slice(-4) || '****'); }
258
+ return String(fv.value);
259
+ };
260
+ const out = [];
261
+ for (const n of [1, 2]) {
262
+ const mine = values.filter((fv) => (fv.signer || 1) === n);
263
+ if (mine.length)
264
+ out.push('Answers (signer ' + n + '): ' + mine.map((fv) => (fv.label || fv.name) + ': ' + render(fv)).join('; '));
265
+ }
266
+ return out;
267
+ }
268
+
154
269
  function stateNote(s) {
270
+ if (s === 'created') return ' <- held: waiting for a payment or files before the document goes out.';
155
271
  if (s === 'partially_signed') return ' <- one of two signers is done. NOT finished.';
156
272
  if (s === 'completed') return ' <- every signature is in.';
157
273
  if (s === 'delivered') return ' <- sent, not opened yet.';
274
+ if (s === 'viewed') return ' <- opened, not signed yet.';
158
275
  if (s === 'voided') return ' <- cancelled, the link no longer works.';
276
+ if (s === 'failed') return ' <- could not be delivered.';
159
277
  return '';
160
278
  }
161
279
 
@@ -164,13 +282,34 @@ const fail = (e) => ({ content: [{ type: 'text', text: 'Failed: ' + (e && e.mess
164
282
 
165
283
  // --- the server ----------------------------------------------------------
166
284
 
167
- const server = new McpServer({ name: 'siglio', version: '1.0.5' });
285
+ const server = new McpServer({ name: 'siglio', version: VERSION });
286
+
287
+ const deliveryEnum = z.enum(['auto', 'email', 'sms', 'both']);
168
288
 
169
289
  const signerShape = z.object({
170
290
  name: z.string().describe("The signer's full name."),
171
- email: z.string().optional().describe('Required if this signer is reached by email.'),
172
- phone: z.string().optional().describe('E.164 preferred, e.g. +18135550142. Required if reached by text.'),
173
- delivery: z.enum(['email', 'sms', 'both']).optional().describe("Overrides the envelope's delivery for this signer."),
291
+ email: z.string().optional().describe('Their email address. Give both email and phone when you have them.'),
292
+ phone: z.string().optional().describe('Their mobile number, E.164 preferred, e.g. +18135550142.'),
293
+ delivery: deliveryEnum.optional().describe("Overrides the envelope's delivery for this signer."),
294
+ });
295
+
296
+ const paymentShape = z.object({
297
+ amount: z.number().int().min(50).max(99999900).describe('Whole cents. 15000 is $150.00.'),
298
+ currency: z.enum(['usd']).optional().describe('usd only.'),
299
+ description: z.string().max(120).optional().describe('What the payment is for, as the signer will read it.'),
300
+ when: z.enum(['before', 'after']).optional().describe(
301
+ 'after (the default): the payment link goes out when the document is signed. before: the document is held ' +
302
+ 'and the signing link goes out once the payment lands.'),
303
+ });
304
+
305
+ const attachmentsShape = z.object({
306
+ when: z.enum(['before', 'after']).describe(
307
+ 'No default, ask the user. before: the document is held until the files are in. after: the upload link ' +
308
+ 'goes out once it is signed.'),
309
+ items: z.array(z.object({
310
+ label: z.string().max(60).describe('What to upload, as the signer reads it: "Photo ID", "Proof of insurance".'),
311
+ required: z.boolean().optional().describe('Defaults to true.'),
312
+ })).min(1).max(5),
174
313
  });
175
314
 
176
315
  server.registerTool('send_for_signature', {
@@ -182,36 +321,62 @@ server.registerTool('send_for_signature', {
182
321
  'The PDF must already contain signature tags: ^S1 where signer one signs, ^I1 for initials, ^D1 for a ' +
183
322
  'date that fills itself, and ^S2 / ^I2 / ^D2 for a second signer. Those tags must be WHITE font or they ' +
184
323
  'print on the finished document. If the document has no tags this call is rejected and nothing is sent.\n\n' +
185
- 'Calling twice with the same document and signers returns the first envelope rather than sending a second ' +
186
- 'copy, so a retry is safe.',
324
+ 'On Enterprise accounts the PDF can also ask the signer questions before they sign: ^M1 (required text), ' +
325
+ '^T1 (optional text), ^C1 (a checkbox), ^R1_G1 (one option of pick-one group 1). Each may carry a name, ' +
326
+ 'and boxes a value: ^M1:policy_number, ^C1:coverage=liability, ^R1_G1:plan=monthly. form_fields sets ' +
327
+ 'labels, types, options, formats and show/require rules; constraints sets rules across fields. Accounts ' +
328
+ 'without the feature are refused with feature_not_enabled and nothing is sent.\n\n' +
329
+ 'Optional extras: payment asks the signer to pay (a deposit, an invoice) through the sender\'s connected ' +
330
+ 'Stripe account; attachments asks the signer to upload files (a photo ID, a W-9).\n\n' +
331
+ 'When the last signer signs, every signer is sent the signed PDF automatically. ' + KEEP_A_COPY + '\n\n' +
332
+ 'Calling twice with the same document, signers and options returns the first envelope rather than sending ' +
333
+ 'a second copy, so a retry is safe.',
187
334
  inputSchema: {
188
335
  document_path: z.string().describe('Full path to the tagged PDF on this machine.'),
189
336
  signers: z.array(signerShape).min(1).max(2)
190
337
  .describe('One or two signers. With two, signing is sequential: the second is not notified until the first finishes.'),
191
- delivery: z.enum(['email', 'sms', 'both']).default('email')
192
- .describe('How to notify the signer. Texting is what Siglio is for and costs the same; ask the user if a text would be better.'),
338
+ delivery: deliveryEnum.default('auto')
339
+ .describe('auto (the default) sends by email and text together when both are known, which is what Siglio ' +
340
+ 'is for, and otherwise by whichever one the signer has. email, sms and both are strict.'),
193
341
  document_name: z.string().optional().describe('What the signer sees it called. Defaults to the filename.'),
194
- idempotency_key: z.string().optional().describe('Rarely needed. One is derived from the document and signers if omitted.'),
342
+ payment: paymentShape.optional().describe('Ask the signer for a payment. Needs Stripe connected in the document studio.'),
343
+ attachments: attachmentsShape.optional().describe('Ask the signer to upload 1 to 5 files.'),
344
+ form_fields: z.array(z.object({ name: z.string() }).passthrough()).max(100).optional()
345
+ .describe('Enterprise. One entry per ^M / ^T / ^C / ^R field to label or configure: { name, signer?, type?, ' +
346
+ 'label?, required?, format?, max_length?, width?, options?, show_if?, required_if? }. Passed to Siglio as is.'),
347
+ constraints: z.array(z.object({ type: z.string() }).passthrough()).max(50).optional()
348
+ .describe('Enterprise. Rules across fields: { type: exactly_one | at_least | at_most | one_of, fields, n? } ' +
349
+ 'or { type: requires, field, when }. Passed to Siglio as is.'),
350
+ idempotency_key: z.string().optional().describe('Rarely needed. One is derived from the document, signers and options if omitted.'),
195
351
  },
196
- }, async ({ document_path, signers, delivery, document_name, idempotency_key }) => {
352
+ }, async ({ document_path, signers, delivery, document_name, payment, attachments, form_fields, constraints, idempotency_key }) => {
197
353
  try {
198
354
  const pdf = loadPdf(document_path);
199
355
  const name = document_name || pdf.name;
200
- const needsPhone = (d) => d === 'sms' || d === 'both';
356
+ // email, sms and both are strict, so catch a missing address before spending a call.
357
+ // auto is resolved by Siglio, which answers 400 when a signer has neither.
201
358
  for (const s of signers) {
202
359
  const d = s.delivery || delivery;
203
- if (needsPhone(d) && !s.phone) throw new Error(s.name + ' is set to receive a text but has no phone number.');
360
+ if ((d === 'sms' || d === 'both') && !s.phone) throw new Error(s.name + ' is set to receive a text but has no phone number.');
204
361
  if ((d === 'email' || d === 'both') && !s.email) throw new Error(s.name + ' is set to receive an email but has no address.');
205
362
  }
206
- const key = idempotency_key ||
207
- 'mcp-' + createHash('sha256').update(pdf.base64 + '|' + name + '|' + delivery + '|' + JSON.stringify(signers)).digest('hex').slice(0, 32);
363
+ const extras = {};
364
+ if (payment) extras.payment = payment;
365
+ if (attachments) extras.attachments = attachments;
366
+ if (form_fields) extras.form_fields = form_fields;
367
+ if (constraints) extras.constraints = constraints;
368
+
369
+ const key = idempotency_key || 'mcp-' + createHash('sha256')
370
+ .update(pdf.base64 + '|' + name + '|' + delivery + '|' + JSON.stringify(signers) + '|' + JSON.stringify(extras))
371
+ .digest('hex').slice(0, 32);
208
372
 
209
- const body = { document_name: name, document_base64: pdf.base64, delivery };
373
+ const body = { document_name: name, document_base64: pdf.base64, delivery, ...extras };
210
374
  if (signers.length === 1) body.signer = signers[0]; else body.signers = signers;
211
375
 
212
376
  const env = await api('POST', '/v1/envelopes', { body, idempotencyKey: key });
213
377
  return text('Sent.\n\n' + describe(env) +
214
378
  '\n\nCheck on it later with check_envelope and this id: ' + env.id +
379
+ '\nWhen the last signer signs, every signer is sent the signed PDF automatically.' +
215
380
  (IS_LIVE ? '\n\nThis was a LIVE envelope and will be billed.' : ''));
216
381
  } catch (e) { return fail(e); }
217
382
  });
@@ -220,7 +385,8 @@ server.registerTool('check_envelope', {
220
385
  title: 'Check an envelope',
221
386
  description:
222
387
  'Returns the current state of an envelope: whether it was delivered, opened, signed, or cancelled, ' +
223
- 'and when each signer signed. Use this to answer "has it been signed yet?".\n\n' +
388
+ 'when each signer signed, and any payment, uploaded files and answers. Use this to answer ' +
389
+ '"has it been signed yet?".\n\n' +
224
390
  'Read the STATE for whether it is signed, not the signer timestamp. On a completed envelope every ' +
225
391
  'signer has signed; a missing signature time only means the time was not recorded.',
226
392
  inputSchema: { envelope_id: z.string().describe('The id returned when it was sent, e.g. env_7mmyc...') },
@@ -233,7 +399,7 @@ server.registerTool('download_signed_document', {
233
399
  title: 'Download the signed PDF',
234
400
  description:
235
401
  'Saves the completed, signed PDF to this machine. Only works once every signature is in; an envelope ' +
236
- 'that is still out, or only partially signed, has no signed document yet.',
402
+ 'that is still out, or only partially signed, has no signed document yet. ' + KEEP_A_COPY,
237
403
  inputSchema: {
238
404
  envelope_id: z.string().describe('The envelope id.'),
239
405
  save_path: z.string().describe('Full path to write the PDF to, e.g. C:\\Users\\me\\Documents\\signed.pdf'),
@@ -243,7 +409,27 @@ server.registerTool('download_signed_document', {
243
409
  const buf = await api('GET', '/v1/envelopes/' + encodeURIComponent(envelope_id) + '/document', { raw: true });
244
410
  const out = resolve(save_path);
245
411
  writeFileSync(out, buf);
246
- return text('Saved the signed document to ' + out + ' (' + (buf.length / 1024).toFixed(0) + ' KB).');
412
+ return text('Saved the signed document to ' + out + ' (' + size(buf.length) + ').');
413
+ } catch (e) { return fail(e); }
414
+ });
415
+
416
+ server.registerTool('download_attachment', {
417
+ title: 'Download a file the signer uploaded',
418
+ description:
419
+ 'Saves one file the signer uploaded (a photo ID, a W-9) to this machine. Get the file id from ' +
420
+ 'check_envelope, where each uploaded file is listed with its id (att_...). ' + KEEP_A_COPY,
421
+ inputSchema: {
422
+ envelope_id: z.string().describe('The envelope id.'),
423
+ file_id: z.string().describe('The file id from check_envelope, e.g. att_0123456789abcdef.'),
424
+ save_path: z.string().describe('Full path to write the file to, keeping its extension, e.g. C:\\Users\\me\\Documents\\id.jpg'),
425
+ },
426
+ }, async ({ envelope_id, file_id, save_path }) => {
427
+ try {
428
+ const buf = await api('GET', '/v1/envelopes/' + encodeURIComponent(envelope_id) + '/attachments/' +
429
+ encodeURIComponent(file_id), { raw: true });
430
+ const out = resolve(save_path);
431
+ writeFileSync(out, buf);
432
+ return text('Saved the file to ' + out + ' (' + size(buf.length) + ').');
247
433
  } catch (e) { return fail(e); }
248
434
  });
249
435
 
@@ -270,4 +456,4 @@ server.registerTool('void_envelope', {
270
456
 
271
457
  const transport = new StdioServerTransport();
272
458
  await server.connect(transport);
273
- process.stderr.write('siglio-mcp ready (' + (IS_LIVE ? 'LIVE' : 'sandbox') + ' key, ' + API_BASE + ')\n');
459
+ process.stderr.write('siglio-mcp ' + VERSION + ' ready (' + (IS_LIVE ? 'LIVE' : 'sandbox') + ' key, ' + API_BASE + ')\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siglio-mcp",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "mcpName": "com.esigndev/siglio-mcp",
5
5
  "description": "MCP server for Siglio. Lets an AI assistant send documents for e-signature by email or text.",
6
6
  "type": "module",