siglio-mcp 1.0.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 +84 -0
  2. package/index.js +263 -0
  3. package/package.json +34 -0
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # siglio-mcp
2
+
3
+ Lets an AI assistant send documents for electronic signature through
4
+ [Siglio](https://esigndev.com), by email, text message, or both at once.
5
+
6
+ It runs on your own machine, so your API key never leaves it.
7
+
8
+ ## Setup
9
+
10
+ Get a key from [your Siglio account](https://esigndev.com/app). Signing up takes
11
+ an email address, no card, and comes with 25 free envelopes.
12
+
13
+ **Claude Desktop** — add this to your config file:
14
+
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "siglio": {
19
+ "command": "npx",
20
+ "args": ["-y", "siglio-mcp"],
21
+ "env": { "SIGLIO_API_KEY": "sig_sandbox_your_key_here" }
22
+ }
23
+ }
24
+ }
25
+ ```
26
+
27
+ Other MCP clients take the same command and environment.
28
+
29
+ ## What it can do
30
+
31
+ | Tool | What it does |
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 |
35
+ | `download_signed_document` | Saves the completed PDF once every signature is in |
36
+ | `void_envelope` | Cancels an unsigned envelope; the link stops working |
37
+
38
+ Then you can just ask: *"Send the NDA on my desktop to Dana at 813 555 0142 by
39
+ text."*
40
+
41
+ ## The one thing to know about your PDF
42
+
43
+ Siglio does not take coordinates for signature fields. Placement comes from
44
+ literal text tags inside the document itself:
45
+
46
+ ```
47
+ ^S1 signer one signs here ^S2 signer two signs here
48
+ ^I1 signer one initials here ^I2 ...
49
+ ^D1 a date that fills itself ^D2 ...
50
+ ```
51
+
52
+ **Set that tag text to white font** before you save the PDF. The tags are
53
+ instructions, not content, and black ones stay visible on the signed document.
54
+
55
+ If the tags are missing the send is rejected and nothing goes out, so you cannot
56
+ accidentally mail someone a document they have no way to sign.
57
+
58
+ ## Two safety decisions worth knowing
59
+
60
+ **Live keys are refused by default.** A key starting with `sig_live_` will not
61
+ run unless you also set `SIGLIO_ALLOW_LIVE=true`. Until you do that, the worst a
62
+ confused assistant can do is send a free sandbox envelope. Sandbox envelopes
63
+ deliver for real and produce real signatures; they just cost nothing.
64
+
65
+ **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.
68
+
69
+ Cancelling additionally requires an explicit confirmation, so it cannot happen as
70
+ a side effect of a vague instruction.
71
+
72
+ ## Environment
73
+
74
+ | Variable | |
75
+ |---|---|
76
+ | `SIGLIO_API_KEY` | Required |
77
+ | `SIGLIO_ALLOW_LIVE` | Set to `true` to permit a live key |
78
+ | `SIGLIO_API_BASE` | Optional, defaults to `https://api.esigndev.com` |
79
+
80
+ ## More
81
+
82
+ - Full API reference: <https://esigndev.com/llms-full.txt>
83
+ - OpenAPI spec: <https://esigndev.com/openapi.json>
84
+ - Docs: <https://esigndev.com/docs>
package/index.js ADDED
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env node
2
+ // Siglio MCP server.
3
+ //
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.
7
+ //
8
+ // Two deliberate safety positions, because these tools send legally binding
9
+ // documents to real people:
10
+ //
11
+ // 1. LIVE KEYS ARE REFUSED unless SIGLIO_ALLOW_LIVE=true is set explicitly.
12
+ // The default blast radius of a confused model is a sandbox envelope.
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.
17
+ //
18
+ // Voiding additionally requires confirm:true, so it cannot happen as a casual
19
+ // side effect of a vague instruction.
20
+ //
21
+ // Environment:
22
+ // SIGLIO_API_KEY required. sig_sandbox_... or sig_live_...
23
+ // SIGLIO_ALLOW_LIVE set to "true" to permit a live key
24
+ // SIGLIO_API_BASE optional, defaults to https://api.esigndev.com
25
+
26
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
27
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
28
+ import { z } from 'zod';
29
+ import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
30
+ import { basename, resolve } from 'node:path';
31
+ import { createHash } from 'node:crypto';
32
+
33
+ const API_BASE = process.env.SIGLIO_API_BASE || 'https://api.esigndev.com';
34
+ const KEY = (process.env.SIGLIO_API_KEY || '').trim();
35
+ const INLINE_LIMIT = 3 * 1024 * 1024; // the API's inline document ceiling
36
+
37
+ function die(message) {
38
+ process.stderr.write('siglio-mcp: ' + message + '\n');
39
+ process.exit(1);
40
+ }
41
+
42
+ if (!KEY) {
43
+ die('SIGLIO_API_KEY is not set. Get a key from https://esigndev.com/app and put it in\n' +
44
+ 'this server\'s env block. Sandbox keys start with sig_sandbox_ and are free.');
45
+ }
46
+ if (/^sig_live_/.test(KEY) && process.env.SIGLIO_ALLOW_LIVE !== 'true') {
47
+ die('That is a LIVE key, which sends billable documents to real signers.\n' +
48
+ 'This server refuses live keys unless you also set SIGLIO_ALLOW_LIVE=true.\n' +
49
+ 'Use a sandbox key while you are building. Sandbox envelopes deliver for real\n' +
50
+ 'and cost nothing.');
51
+ }
52
+ const IS_LIVE = /^sig_live_/.test(KEY);
53
+
54
+ // --- talking to the API --------------------------------------------------
55
+
56
+ async function api(method, path, { body, idempotencyKey, raw } = {}) {
57
+ const headers = { Authorization: 'Bearer ' + KEY, 'User-Agent': 'siglio-mcp/1.0' };
58
+ if (body) headers['Content-Type'] = 'application/json';
59
+ if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
60
+
61
+ let res;
62
+ try {
63
+ res = await fetch(API_BASE + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
64
+ } catch (e) {
65
+ throw new Error('Could not reach the Siglio API (' + (e && e.message) + '). Check the network and try again.');
66
+ }
67
+ if (raw && res.ok) return Buffer.from(await res.arrayBuffer());
68
+
69
+ const text = await res.text();
70
+ let parsed = null;
71
+ try { parsed = JSON.parse(text); } catch {}
72
+ if (res.ok) return parsed;
73
+
74
+ throw new Error(explain(res.status, parsed, res.headers.get('retry-after')));
75
+ }
76
+
77
+ // Turn an API error into something an assistant can act on rather than repeat.
78
+ function explain(status, parsed, retryAfter) {
79
+ const e = (parsed && parsed.error) || {};
80
+ const rid = e.request_id ? ' (request_id ' + e.request_id + ')' : '';
81
+ switch (e.type) {
82
+ case 'missing_required_tag':
83
+ return 'The PDF does not carry the signature tags the request needs' + rid + '. ' +
84
+ 'Placement comes from literal text inside the document: put ^S1 where signer one signs, ' +
85
+ '^I1 for initials, ^D1 for a self-filling date, and ^S2 / ^I2 / ^D2 for a second signer. ' +
86
+ 'A document tagged for two signers must be sent with two, and one tagged for one with one. ' +
87
+ 'Set that tag text to WHITE so it does not print on the finished document. ' +
88
+ 'Siglio message: ' + (e.message || '');
89
+ case 'invalid_document':
90
+ return 'Siglio could not use that PDF' + rid + ': ' + (e.message || '') +
91
+ ' Check it opens normally and is a real PDF rather than a renamed file.';
92
+ case 'authentication_error':
93
+ return 'The API key was rejected' + rid + '. Get a current key from https://esigndev.com/app.';
94
+ case 'payment_required':
95
+ return 'This account needs a card on file before it can send' + rid +
96
+ '. Add one at https://esigndev.com/app, or use a sandbox key, which is free.';
97
+ case 'usage_limit_reached':
98
+ return 'This account is out of its free allowance' + rid +
99
+ '. Add a card at https://esigndev.com/app to keep sending.';
100
+ case 'rate_limited':
101
+ return 'Rate limited' + rid + '. Wait ' + (retryAfter || 'a few') + ' seconds and retry with the same idempotency key.';
102
+ case 'service_unavailable':
103
+ return 'Siglio is temporarily unavailable' + rid + '. Retrying with the same idempotency key is safe.';
104
+ default:
105
+ return 'Siglio returned ' + status + rid + ': ' + (e.message || JSON.stringify(parsed) || 'no detail');
106
+ }
107
+ }
108
+
109
+ // --- helpers -------------------------------------------------------------
110
+
111
+ function loadPdf(p) {
112
+ const full = resolve(p);
113
+ if (!existsSync(full)) throw new Error('No file at ' + full + '. Give the full path to the PDF.');
114
+ const st = statSync(full);
115
+ if (!st.isFile()) throw new Error(full + ' is not a file.');
116
+ const buf = readFileSync(full);
117
+ if (buf.subarray(0, 5).toString('latin1') !== '%PDF-')
118
+ throw new Error(full + ' is not a PDF. Siglio signs PDFs only; convert it first.');
119
+ if (buf.length > INLINE_LIMIT)
120
+ throw new Error('That PDF is ' + (buf.length / 1048576).toFixed(1) + ' MB. This server sends documents ' +
121
+ 'inline, which tops out around 3 MB. Compress it, or use the upload endpoint directly ' +
122
+ '(see https://esigndev.com/llms-full.txt).');
123
+ return { base64: buf.toString('base64'), bytes: buf.length, name: basename(full) };
124
+ }
125
+
126
+ function describe(env) {
127
+ const lines = [
128
+ 'Envelope ' + env.id,
129
+ 'State: ' + env.state + stateNote(env.state),
130
+ 'Document: ' + env.document_name,
131
+ 'Delivery: ' + env.delivery,
132
+ 'Environment: ' + env.environment + (env.environment === 'sandbox' ? ' (free, delivers for real)' : ' (billable)'),
133
+ ];
134
+ const people = env.signers || (env.signer ? [env.signer] : []);
135
+ people.forEach((s, i) => {
136
+ lines.push('Signer ' + (i + 1) + ': ' + s.name +
137
+ [s.email, s.phone].filter(Boolean).map((x) => ' <' + x + '>').join('') +
138
+ (s.signed_at ? ' — signed ' + s.signed_at : ' — not yet signed'));
139
+ if (s.signing_url) lines.push(' link: ' + s.signing_url);
140
+ });
141
+ if (!people.length && env.signing_url) lines.push('Signing link: ' + env.signing_url);
142
+ if (env.completed_at) lines.push('Completed: ' + env.completed_at);
143
+ return lines.join('\n');
144
+ }
145
+
146
+ function stateNote(s) {
147
+ if (s === 'partially_signed') return ' <- one of two signers is done. NOT finished.';
148
+ if (s === 'completed') return ' <- every signature is in.';
149
+ if (s === 'delivered') return ' <- sent, not opened yet.';
150
+ if (s === 'voided') return ' <- cancelled, the link no longer works.';
151
+ return '';
152
+ }
153
+
154
+ const text = (s) => ({ content: [{ type: 'text', text: s }] });
155
+ const fail = (e) => ({ content: [{ type: 'text', text: 'Failed: ' + (e && e.message ? e.message : String(e)) }], isError: true });
156
+
157
+ // --- the server ----------------------------------------------------------
158
+
159
+ const server = new McpServer({ name: 'siglio', version: '1.0.0' });
160
+
161
+ const signerShape = z.object({
162
+ name: z.string().describe("The signer's full name."),
163
+ email: z.string().optional().describe('Required if this signer is reached by email.'),
164
+ phone: z.string().optional().describe('E.164 preferred, e.g. +18135550142. Required if reached by text.'),
165
+ delivery: z.enum(['email', 'sms', 'both']).optional().describe("Overrides the envelope's delivery for this signer."),
166
+ });
167
+
168
+ server.registerTool('send_for_signature', {
169
+ title: 'Send a document for signature',
170
+ description:
171
+ 'Sends a PDF to one or two people for electronic signature, by email, text message, or both at once. ' +
172
+ 'Delivery happens IMMEDIATELY and the result is a legally binding signature, so confirm the recipient and ' +
173
+ 'the document with the user before calling this.\n\n' +
174
+ 'The PDF must already contain signature tags: ^S1 where signer one signs, ^I1 for initials, ^D1 for a ' +
175
+ 'date that fills itself, and ^S2 / ^I2 / ^D2 for a second signer. Those tags must be WHITE font or they ' +
176
+ 'print on the finished document. If the document has no tags this call is rejected and nothing is sent.\n\n' +
177
+ 'Calling twice with the same document and signers returns the first envelope rather than sending a second ' +
178
+ 'copy, so a retry is safe.',
179
+ inputSchema: {
180
+ document_path: z.string().describe('Full path to the tagged PDF on this machine.'),
181
+ signers: z.array(signerShape).min(1).max(2)
182
+ .describe('One or two signers. With two, signing is sequential: the second hears nothing until the first finishes.'),
183
+ delivery: z.enum(['email', 'sms', 'both']).default('email')
184
+ .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.'),
185
+ document_name: z.string().optional().describe('What the signer sees it called. Defaults to the filename.'),
186
+ idempotency_key: z.string().optional().describe('Rarely needed. One is derived from the document and signers if omitted.'),
187
+ },
188
+ }, async ({ document_path, signers, delivery, document_name, idempotency_key }) => {
189
+ try {
190
+ const pdf = loadPdf(document_path);
191
+ const name = document_name || pdf.name;
192
+ const needsPhone = (d) => d === 'sms' || d === 'both';
193
+ for (const s of signers) {
194
+ const d = s.delivery || delivery;
195
+ if (needsPhone(d) && !s.phone) throw new Error(s.name + ' is set to receive a text but has no phone number.');
196
+ if ((d === 'email' || d === 'both') && !s.email) throw new Error(s.name + ' is set to receive an email but has no address.');
197
+ }
198
+ const key = idempotency_key ||
199
+ 'mcp-' + createHash('sha256').update(pdf.base64 + '|' + name + '|' + delivery + '|' + JSON.stringify(signers)).digest('hex').slice(0, 32);
200
+
201
+ const body = { document_name: name, document_base64: pdf.base64, delivery };
202
+ if (signers.length === 1) body.signer = signers[0]; else body.signers = signers;
203
+
204
+ const env = await api('POST', '/v1/envelopes', { body, idempotencyKey: key });
205
+ return text('Sent.\n\n' + describe(env) +
206
+ '\n\nCheck on it later with check_envelope and this id: ' + env.id +
207
+ (IS_LIVE ? '\n\nThis was a LIVE envelope and will be billed.' : ''));
208
+ } catch (e) { return fail(e); }
209
+ });
210
+
211
+ server.registerTool('check_envelope', {
212
+ title: 'Check an envelope',
213
+ description:
214
+ 'Returns the current state of an envelope: whether it was delivered, opened, signed, or cancelled, ' +
215
+ 'and when each signer signed. Use this to answer "has it been signed yet?".',
216
+ inputSchema: { envelope_id: z.string().describe('The id returned when it was sent, e.g. env_7mmyc...') },
217
+ }, async ({ envelope_id }) => {
218
+ try { return text(describe(await api('GET', '/v1/envelopes/' + encodeURIComponent(envelope_id)))); }
219
+ catch (e) { return fail(e); }
220
+ });
221
+
222
+ server.registerTool('download_signed_document', {
223
+ title: 'Download the signed PDF',
224
+ description:
225
+ 'Saves the completed, signed PDF to this machine. Only works once every signature is in; an envelope ' +
226
+ 'that is still out, or only partially signed, has no signed document yet.',
227
+ inputSchema: {
228
+ envelope_id: z.string().describe('The envelope id.'),
229
+ save_path: z.string().describe('Full path to write the PDF to, e.g. /Users/me/Documents/signed.pdf'),
230
+ },
231
+ }, async ({ envelope_id, save_path }) => {
232
+ try {
233
+ const buf = await api('GET', '/v1/envelopes/' + encodeURIComponent(envelope_id) + '/document', { raw: true });
234
+ const out = resolve(save_path);
235
+ writeFileSync(out, buf);
236
+ return text('Saved the signed document to ' + out + ' (' + (buf.length / 1024).toFixed(0) + ' KB).');
237
+ } catch (e) { return fail(e); }
238
+ });
239
+
240
+ server.registerTool('void_envelope', {
241
+ title: 'Cancel an envelope',
242
+ description:
243
+ 'Cancels an envelope that has not been signed yet. The signing link stops working immediately and the ' +
244
+ 'signer sees a cancellation page. This cannot be undone, and an already-completed document cannot be ' +
245
+ 'voided. Ask the user to confirm in their own words before calling this, then pass confirm:true.',
246
+ inputSchema: {
247
+ envelope_id: z.string().describe('The envelope id to cancel.'),
248
+ confirm: z.boolean().describe('Must be true. Set it only after the user has explicitly agreed to cancel this specific envelope.'),
249
+ },
250
+ }, async ({ envelope_id, confirm }) => {
251
+ if (confirm !== true) {
252
+ return text('Not cancelled. void_envelope needs confirm:true, and you should only set that after the ' +
253
+ 'user has agreed to cancel this specific envelope. Ask them first.');
254
+ }
255
+ try {
256
+ const env = await api('DELETE', '/v1/envelopes/' + encodeURIComponent(envelope_id));
257
+ return text('Cancelled. The signing link no longer works.\n\n' + describe(env));
258
+ } catch (e) { return fail(e); }
259
+ });
260
+
261
+ const transport = new StdioServerTransport();
262
+ await server.connect(transport);
263
+ process.stderr.write('siglio-mcp ready (' + (IS_LIVE ? 'LIVE' : 'sandbox') + ' key, ' + API_BASE + ')\n');
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "siglio-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Siglio. Lets an AI assistant send documents for e-signature by email or text.",
5
+ "type": "module",
6
+ "bin": {
7
+ "siglio-mcp": "index.js"
8
+ },
9
+ "main": "index.js",
10
+ "files": [
11
+ "index.js",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "keywords": [
18
+ "mcp",
19
+ "modelcontextprotocol",
20
+ "esignature",
21
+ "siglio",
22
+ "signature",
23
+ "pdf"
24
+ ],
25
+ "license": "MIT",
26
+ "homepage": "https://esigndev.com",
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.30.0",
29
+ "zod": "^3.23.8"
30
+ },
31
+ "devDependencies": {
32
+ "pdf-lib": "^1.17.1"
33
+ }
34
+ }