@vumasign/mcp 0.1.0 → 0.3.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.
package/README.md CHANGED
@@ -1,11 +1,16 @@
1
1
  # @vumasign/mcp
2
2
 
3
- An MCP server for a Vumasign account. An agent can read your templates and their pages,
3
+ An MCP server for a Vumasign account. An agent can read your templates, their pages and the core
4
+ vocabulary with your organisation’s own keys,
4
5
  check the values it means to fill in, and create **draft** envelopes. A person reviews each draft in
5
6
  Vumasign and sends it.
6
7
 
7
- It never sends an envelope, never mints a signing URL, never renames a field, never creates a template
8
- and never touches a webhook. There is no tool for any of them.
8
+ It never sends an envelope, never mints a signing URL, never names a field freely, never creates a
9
+ template and never touches a webhook. There is no tool for any of them.
10
+
11
+ Three tools change a template, and exist only when `VUMASIGN_MCP_ALLOW_TEMPLATE_WRITES=1` is set:
12
+ `request_naming`, `accept_naming` and `accept_person`. They accept Vumasign’s own naming of a
13
+ template and nothing of the agent’s, and need a live key that also names `templates:name`.
9
14
 
10
15
  ## Use
11
16
 
package/dist/client.js CHANGED
@@ -44,6 +44,10 @@ export async function callTool(tool, args, config, fetchImpl) {
44
44
  let request;
45
45
  let url;
46
46
  try {
47
+ // The flag is the operator's, not the model's: a write tool is refused without it, however it was reached.
48
+ if (tool.templateWrite === true && config.templateWrites !== true) {
49
+ throw new ToolRefusal('refused_by_mcp_server', `${tool.name} writes to a template, and this server was not started with VUMASIGN_MCP_ALLOW_TEMPLATE_WRITES=1.`);
50
+ }
47
51
  request = tool.build(args);
48
52
  url = requestUrl(tool, request, config.apiOrigin);
49
53
  }
@@ -76,12 +80,14 @@ export async function callTool(tool, args, config, fetchImpl) {
76
80
  // committed the request. A repeat with the same arguments replays it.
77
81
  return failure('network_error',
78
82
  // Keyed on the tool, not the method: validate_values is a POST that writes nothing.
79
- tool.name === 'create_envelope_draft'
80
- ? `The request to the Vumasign API at ${config.apiOrigin} failed before an answer came back, so the ` +
81
- 'draft may or may not have been made. Call again with exactly the same arguments: a draft that was ' +
82
- 'made is then returned rather than made twice.'
83
- : `Could not reach the Vumasign API at ${config.apiOrigin}. This tool writes nothing, so call it again ` +
84
- 'when the connection is back.', config.apiKey);
83
+ tool.uncertainOutcome !== undefined
84
+ ? tool.uncertainOutcome
85
+ : tool.name === 'create_envelope_draft'
86
+ ? `The request to the Vumasign API at ${config.apiOrigin} failed before an answer came back, so the ` +
87
+ 'draft may or may not have been made. Call again with exactly the same arguments: a draft that was ' +
88
+ 'made is then returned rather than made twice.'
89
+ : `Could not reach the Vumasign API at ${config.apiOrigin}. This tool writes nothing, so call it again ` +
90
+ 'when the connection is back.', config.apiKey);
85
91
  }
86
92
  if (!response.ok)
87
93
  return refusal(response, config.apiKey);
package/dist/config.js CHANGED
@@ -12,6 +12,12 @@
12
12
  export const API_KEY_ENV = 'VUMASIGN_API_KEY';
13
13
  /** The opt-in a live key needs. Exactly `1`; nothing else counts. */
14
14
  export const ALLOW_LIVE_ENV = 'VUMASIGN_MCP_ALLOW_LIVE';
15
+ /**
16
+ * The opt-in for the tools that write to a template: `request_naming`,
17
+ * `accept_naming` and `accept_person` (spec 2026-10-11, 15.3). Exactly `1`;
18
+ * nothing else counts. Without it they are not offered at all.
19
+ */
20
+ export const ALLOW_TEMPLATE_WRITES_ENV = 'VUMASIGN_MCP_ALLOW_TEMPLATE_WRITES';
15
21
  /** Where the API is. Optional; for pointing the server at a local instance. */
16
22
  export const API_URL_ENV = 'VUMASIGN_API_URL';
17
23
  export const DEFAULT_API_URL = 'https://app.vumasign.com';
@@ -69,7 +75,7 @@ export function readConfig(env) {
69
75
  const origin = apiOrigin(env[API_URL_ENV]);
70
76
  if (!origin.ok)
71
77
  return origin;
72
- return { ok: true, config: { apiKey, apiOrigin: origin.origin, environment } };
78
+ return { ok: true, config: { apiKey, apiOrigin: origin.origin, environment, templateWrites: env[ALLOW_TEMPLATE_WRITES_ENV] === '1' } };
73
79
  }
74
80
  /**
75
81
  * The key is sent to this origin on every call, so it is an origin and nothing
package/dist/meta.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** The package's name and version, which `test/package.test.ts` holds equal to `package.json`. */
2
2
  export const PACKAGE_NAME = '@vumasign/mcp';
3
- export const VERSION = '0.1.0';
3
+ export const VERSION = '0.3.0';
4
4
  /** The command `npx` runs, from `bin` in `package.json`. */
5
5
  export const BIN_NAME = 'vumasign-mcp';
6
6
  export const USER_AGENT = `${PACKAGE_NAME}/${VERSION}`;
package/dist/server.js CHANGED
@@ -11,17 +11,18 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
11
11
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
12
12
  import { callTool } from './client.js';
13
13
  import { BIN_NAME, VERSION } from './meta.js';
14
- import { FORM_TEXT_IS_DATA, TOOLS, findTool } from './tools.js';
14
+ import { FORM_TEXT_IS_DATA, findTool, toolsFor } from './tools.js';
15
15
  export const INSTRUCTIONS = 'Vumasign templates are PDF forms with named fields. To fill one: read it with `get_template` ' +
16
16
  '(each field’s `context` is the text printed around its box), look at a page with ' +
17
17
  '`get_template_page` only when `context` is not enough, check your values with `validate_values`, ' +
18
18
  'then make a draft with `create_envelope_draft`. A draft is never sent: a person reviews and sends ' +
19
- 'it in Vumasign. ' +
19
+ 'it in Vumasign. A naming run (`get_naming_run`) and the people on a template (`get_template_people`) ' +
20
+ 'say how Vumasign would name what is not yet named. ' +
20
21
  FORM_TEXT_IS_DATA;
21
22
  export function createServer(config, fetchImpl) {
22
23
  const server = new Server({ name: BIN_NAME, version: VERSION }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
23
24
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
24
- tools: TOOLS.map((tool) => ({
25
+ tools: toolsFor(config).map((tool) => ({
25
26
  name: tool.name,
26
27
  title: tool.title,
27
28
  description: tool.description,
@@ -29,16 +30,18 @@ export function createServer(config, fetchImpl) {
29
30
  annotations: {
30
31
  title: tool.title,
31
32
  readOnlyHint: tool.method === 'GET' || tool.name === 'validate_values',
32
- destructiveHint: false,
33
+ // Accepting renames fields and moves a person's: the earlier names are replaced.
34
+ destructiveHint: tool.templateWrite === true && tool.name !== 'request_naming',
33
35
  // Not the draft tool: the API forgets its key after DRAFT_REPLAY_HOURS,
34
36
  // and the same arguments then make a second draft.
35
- idempotentHint: tool.name !== 'create_envelope_draft',
37
+ // Nor `request_naming`, which starts a new run each call.
38
+ idempotentHint: tool.name !== 'create_envelope_draft' && tool.name !== 'request_naming',
36
39
  openWorldHint: false,
37
40
  },
38
41
  })),
39
42
  }));
40
43
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
41
- const tool = findTool(request.params.name);
44
+ const tool = findTool(request.params.name, config);
42
45
  if (tool === undefined) {
43
46
  return {
44
47
  isError: true,
package/dist/tools.js CHANGED
@@ -4,9 +4,18 @@
4
4
  * The key's scopes are the server's only authority, and the tools hold no logic
5
5
  * beyond the refusals below (spec 2026-10-11, 15.2). What is NOT here matters as
6
6
  * much as what is: no tool sends an envelope, mints a signing URL, renames a
7
- * field, creates a template or touches a webhook (spec 2026-08-19, 6). That is
8
- * enforced by absence, and by a test that compares every route below with the
9
- * published OpenAPI document.
7
+ * field freely, creates a template or touches a webhook (spec 2026-08-19, 6).
8
+ * That is enforced by absence, and by a test that compares every route below
9
+ * with the published OpenAPI document.
10
+ *
11
+ * ⚠️ THREE TOOLS WRITE TO A TEMPLATE, AND EXIST ONLY BEHIND A FLAG (spec
12
+ * 2026-10-11, 15.3; template field naming PR 9): `request_naming`,
13
+ * `accept_naming` and `accept_person`, offered only when the server is started
14
+ * with VUMASIGN_MCP_ALLOW_TEMPLATE_WRITES=1, and working only with a live key
15
+ * that names `templates:name`. They accept Vumasign's own naming and nothing
16
+ * else: `accept_naming` takes no `overrides`, and `accept_person` takes a
17
+ * person's id and no decision, so an agent cannot invent a name or move a
18
+ * person's fields where it likes.
10
19
  *
11
20
  * ⚠️ PATH SEGMENTS ARE CHECKED, NOT ESCAPED INTO SAFETY. An id goes into a URL
12
21
  * path, so `../webhooks` or `x/send` as a `template_id` would otherwise be a way
@@ -65,6 +74,14 @@ export const FORBIDDEN_CAPABILITIES = [
65
74
  reason: 'Naming a template\u2019s fields is for a person or for Vumasign\u2019s own naming, not for an agent ' +
66
75
  'inventing names.',
67
76
  },
77
+ {
78
+ capability: 'Accept a naming run with names of its own, or decide a person freely',
79
+ operationIds: ['acceptNamingRun', 'decideTemplatePerson'],
80
+ reason: 'Names an agent typed would pass every naming rule and could still swap two people\u2019s details, such as ' +
81
+ 'the main member\u2019s ID number and the spouse\u2019s, and a free decision could move a person\u2019s ' +
82
+ 'signature boxes to another role. The template-write tools accept only Vumasign\u2019s own proposals, through ' +
83
+ '`accept-current` and `accept-proposal`.',
84
+ },
68
85
  {
69
86
  capability: 'Create a template, or an envelope from a document of its own',
70
87
  operationIds: ['createTemplate', 'createOneOffEnvelope'],
@@ -161,6 +178,41 @@ function optionalObject(args, name) {
161
178
  }
162
179
  return value;
163
180
  }
181
+ /** A person's id, as `GET …/people` gives it: 32 lower-case hexadecimal digits. */
182
+ function personId(args, name) {
183
+ const value = args[name];
184
+ if (typeof value !== 'string' || !/^[0-9a-f]{32}$/.test(value)) {
185
+ throw new ToolRefusal('invalid_request', `\`${name}\` must be a person’s id from \`get_template_people\`: 32 lower-case hexadecimal digits.`);
186
+ }
187
+ return value;
188
+ }
189
+ /** Refuse any argument but these: a write tool takes nothing it does not name. */
190
+ function only(args, names, why) {
191
+ const extra = Object.keys(args).filter((name) => !names.includes(name) && !absent(args[name]));
192
+ if (extra.length > 0) {
193
+ throw new ToolRefusal('refused_by_mcp_server', `This tool takes no \`${extra.join('`, `')}\`. ${why}`);
194
+ }
195
+ }
196
+ /** `accept`: `"all"`, `"high"`, or lists of the run's own ids. */
197
+ function acceptSelection(args) {
198
+ const value = args.accept;
199
+ if (value === 'all' || value === 'high')
200
+ return value;
201
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
202
+ throw new ToolRefusal('invalid_request', '`accept` must be "all", "high", or an object of `fields`, `questions` and `people` id lists.');
203
+ }
204
+ const selection = {};
205
+ for (const [key, list] of Object.entries(value)) {
206
+ if (!['fields', 'questions', 'people'].includes(key)) {
207
+ throw new ToolRefusal('invalid_request', `\`accept.${key}\` is not a list this tool takes: \`fields\`, \`questions\` or \`people\`.`);
208
+ }
209
+ if (!Array.isArray(list) || list.some((id) => typeof id !== 'string')) {
210
+ throw new ToolRefusal('invalid_request', `\`accept.${key}\` must be a list of ids from the run’s proposals.`);
211
+ }
212
+ selection[key] = list;
213
+ }
214
+ return selection;
215
+ }
164
216
  /** JSON with every object's keys sorted, so equal arguments give equal bytes. */
165
217
  export function canonicalJson(value) {
166
218
  if (Array.isArray(value))
@@ -185,7 +237,9 @@ const uuidProperty = (description) => ({ type: 'string', format: 'uuid', descrip
185
237
  const valuesProperty = {
186
238
  type: 'array',
187
239
  description: 'The values to put on the template, exactly as `POST /api/v1/envelopes` takes them: each entry ' +
188
- 'names a field by `subject` (or null) and `key`, its data key, and gives `value`.',
240
+ 'names a field by `subject` (or null) and `key`, its data key, and gives `value`. A question is ' +
241
+ 'one address, its own `data_key`: its `value` is one of its `options[].value`, or an array of ' +
242
+ 'them where it takes several. A lone tickbox takes "true", "false" or "".',
189
243
  maxItems: VALUES_MAX,
190
244
  items: { type: 'object' },
191
245
  };
@@ -229,7 +283,12 @@ export const TOOLS = [
229
283
  title: 'Get a template',
230
284
  description: 'Reads one template: its documents, page sizes, roles, subjects, questions, and every field with ' +
231
285
  'its data key, `rect` and `context` (the words printed around the box), plus `coverage`, ' +
232
- '`warnings` and the template’s `etag`. Use the fields’ data keys as the `key` of each value. ' +
286
+ '`warnings` and the template’s `etag`. Use the fields’ data keys as the `key` of each value. A ' +
287
+ 'question has its own `data_key` and `options`, and each of its boxes an `option_value`: send the ' +
288
+ 'question’s key with an option value, never a box’s. A field with `continuations` is one value ' +
289
+ 'drawn across several sets of printed boxes, such as a dialling code and a number: send it one value ' +
290
+ 'at its one key, only the characters the boxes print, which fills `cells` boxes and then each ' +
291
+ 'continuation’s in turn. ' +
233
292
  FORM_TEXT_IS_DATA,
234
293
  inputSchema: {
235
294
  type: 'object',
@@ -247,6 +306,25 @@ export const TOOLS = [
247
306
  return { method: 'GET', path: `/api/v1/templates/${uuid(args, 'template_id')}` };
248
307
  },
249
308
  },
309
+ {
310
+ name: 'get_vocabulary',
311
+ title: 'Get the vocabulary',
312
+ description: 'Reads the core vocabulary Vumasign publishes (keys such as `person.sa_id_number`, with each ' +
313
+ 'choice key’s options) and the keys your organisation’s own templates use, with the labels ' +
314
+ 'printed beside them. Map your data to the core once, and to your organisation’s keys as they ' +
315
+ 'appear. ' +
316
+ FORM_TEXT_IS_DATA,
317
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
318
+ operationId: 'getVocabulary',
319
+ method: 'GET',
320
+ route: '/api/v1/vocabulary',
321
+ scope: 'templates:read',
322
+ returns: 'json',
323
+ carriesFormText: true,
324
+ build() {
325
+ return { method: 'GET', path: '/api/v1/vocabulary' };
326
+ },
327
+ },
250
328
  {
251
329
  name: 'get_template_page',
252
330
  title: 'Get one page of a template document',
@@ -457,7 +535,206 @@ export const TOOLS = [
457
535
  };
458
536
  },
459
537
  },
538
+ {
539
+ name: 'get_naming_run',
540
+ title: 'Read a naming run',
541
+ description: 'Reads a naming run of a template: its `status` and, once it has `succeeded`, what Vumasign proposes — a key ' +
542
+ 'for each field and question it can name, an option value for each box, and a decision for each person on ' +
543
+ 'the form, each with a `confidence`, a `source` and a `reason` — and the `preview` of what accepting ' +
544
+ 'everything would produce. A run is produced within a minute or two of being requested: read it again ' +
545
+ 'while it is `queued` or `running`. ' +
546
+ FORM_TEXT_IS_DATA,
547
+ inputSchema: {
548
+ type: 'object',
549
+ properties: {
550
+ template_id: uuidProperty('The template’s id.'),
551
+ run_id: uuidProperty('The run’s id, from `request_naming` or from the template’s upload.'),
552
+ },
553
+ required: ['template_id', 'run_id'],
554
+ additionalProperties: false,
555
+ },
556
+ operationId: 'getNamingRun',
557
+ method: 'GET',
558
+ route: '/api/v1/templates/{templateId}/naming-runs/{runId}',
559
+ scope: 'templates:read',
560
+ returns: 'json',
561
+ carriesFormText: true,
562
+ build(args) {
563
+ return { method: 'GET', path: `/api/v1/templates/${uuid(args, 'template_id')}/naming-runs/${uuid(args, 'run_id')}` };
564
+ },
565
+ },
566
+ {
567
+ name: 'get_template_people',
568
+ title: 'Get the people on a template',
569
+ description: 'Lists the people a template is about or signed by — a heading such as “Spouse” or “Dependant 2”, or the ' +
570
+ 'caption under a signature line — each with a stable `id`, the decision Vumasign proposes and why, the ' +
571
+ 'decisions it may take, the fields in that part of the form, and what already exists for it. It writes ' +
572
+ 'nothing. ' +
573
+ FORM_TEXT_IS_DATA,
574
+ inputSchema: {
575
+ type: 'object',
576
+ properties: { template_id: uuidProperty('The template’s id.') },
577
+ required: ['template_id'],
578
+ additionalProperties: false,
579
+ },
580
+ operationId: 'listTemplatePeople',
581
+ method: 'GET',
582
+ route: '/api/v1/templates/{templateId}/people',
583
+ scope: 'templates:read',
584
+ returns: 'json',
585
+ carriesFormText: true,
586
+ build(args) {
587
+ return { method: 'GET', path: `/api/v1/templates/${uuid(args, 'template_id')}/people` };
588
+ },
589
+ },
590
+ {
591
+ name: 'request_naming',
592
+ title: 'Ask Vumasign to name a template',
593
+ description: 'Asks Vumasign to propose names for a template’s fields, questions and people: a naming run. It changes ' +
594
+ 'nothing on the template. Read the run with `get_naming_run` until it has `succeeded`, show the person ' +
595
+ 'its `preview`, then accept it with `accept_naming`. `use_ai` asks for a model as well, and then ' +
596
+ '`blank_form` must be true: confirm with the person that the form is blank, with nobody’s details on it.',
597
+ inputSchema: {
598
+ type: 'object',
599
+ properties: {
600
+ template_id: uuidProperty('The template’s id.'),
601
+ use_ai: { type: 'boolean', description: 'Ask for a model as well as the vocabulary. Default false.' },
602
+ blank_form: {
603
+ type: 'boolean',
604
+ description: 'Required, as true, with `use_ai`: the person confirms the form carries nobody’s details.',
605
+ },
606
+ request_id: {
607
+ type: 'string',
608
+ description: 'Leave it out normally: calling again with the same arguments within ' +
609
+ `${DRAFT_REPLAY_HOURS} hours returns the run the first call started. To ask for a fresh run on purpose (the ` +
610
+ 'template changed, so accepting the first was refused), pass any new value here.',
611
+ },
612
+ },
613
+ required: ['template_id'],
614
+ additionalProperties: false,
615
+ },
616
+ operationId: 'requestNamingRun',
617
+ method: 'POST',
618
+ route: '/api/v1/templates/{templateId}/naming-runs',
619
+ scope: 'templates:name',
620
+ returns: 'json',
621
+ carriesFormText: false,
622
+ templateWrite: true,
623
+ uncertainOutcome: 'The request to the Vumasign API failed before an answer came back, so a naming run may or may not have been started. Call again with exactly the same arguments: a run that was started is then returned rather than started twice.',
624
+ build(args) {
625
+ const templateId = uuid(args, 'template_id');
626
+ const body = {};
627
+ for (const name of ['use_ai', 'blank_form']) {
628
+ if (absent(args[name]))
629
+ continue;
630
+ if (typeof args[name] !== 'boolean')
631
+ throw new ToolRefusal('invalid_request', `\`${name}\` must be true or false.`);
632
+ body[name] = args[name];
633
+ }
634
+ if (body.use_ai === true && body.blank_form !== true) {
635
+ throw new ToolRefusal('invalid_request', '`use_ai` needs `blank_form: true`. Ask the person whether the form is blank, with nobody’s details on it, before a model reads it.');
636
+ }
637
+ /*
638
+ ONE KEY PER LOGICAL REQUEST (Codex on PR 206): a digest of the template
639
+ and the body, and of `request_id` when given, so a retry after a lost
640
+ answer replays the run the first call started rather than starting a
641
+ second. A fresh run is asked for with a new `request_id`.
642
+ */
643
+ const requestId = optionalString(args, 'request_id');
644
+ const digest = createHash('sha256')
645
+ .update(canonicalJson({ template_id: templateId, body, ...(requestId === undefined ? {} : { request_id: requestId }) }))
646
+ .digest('hex');
647
+ return { method: 'POST', path: `/api/v1/templates/${templateId}/naming-runs`, body, headers: { 'Idempotency-Key': `vumasign-mcp-naming-${digest}` } };
648
+ },
649
+ },
650
+ {
651
+ name: 'accept_naming',
652
+ title: 'Accept a naming run',
653
+ description: 'Applies a naming run’s own proposals to the template, all of them or some, in one change — if the template ' +
654
+ 'has not changed since the run was made; if it has, request a new run. Show the person the run’s `preview` ' +
655
+ 'first. `accept` is "all", "high" (only high-confidence proposals, never a model’s, and no person), or lists ' +
656
+ 'of the run’s own ids. It takes no names of its own: to name a field differently, the person does it in ' +
657
+ 'Vumasign.',
658
+ inputSchema: {
659
+ type: 'object',
660
+ properties: {
661
+ template_id: uuidProperty('The template’s id.'),
662
+ run_id: uuidProperty('The run’s id.'),
663
+ accept: {
664
+ description: '"all", "high", or `{ "fields": [...], "questions": [...], "people": [...] }` with ids from the run’s proposals.',
665
+ anyOf: [
666
+ { type: 'string', enum: ['all', 'high'] },
667
+ {
668
+ type: 'object',
669
+ properties: {
670
+ fields: { type: 'array', items: { type: 'string' } },
671
+ questions: { type: 'array', items: { type: 'string' } },
672
+ people: { type: 'array', items: { type: 'string' } },
673
+ },
674
+ additionalProperties: false,
675
+ },
676
+ ],
677
+ },
678
+ },
679
+ required: ['template_id', 'run_id', 'accept'],
680
+ additionalProperties: false,
681
+ },
682
+ operationId: 'acceptNamingRunCurrent',
683
+ method: 'POST',
684
+ route: '/api/v1/templates/{templateId}/naming-runs/{runId}/accept-current',
685
+ scope: 'templates:name',
686
+ returns: 'json',
687
+ carriesFormText: true,
688
+ templateWrite: true,
689
+ uncertainOutcome: 'The request to the Vumasign API failed before an answer came back, so the run’s names may or may not have been applied to the template. Do not simply retry: read the template with `get_template` (or the run with `get_naming_run`) first. If they were applied, the template has changed and accepting again is refused as `template_changed`.',
690
+ build(args) {
691
+ // ⚠️ NO OVERRIDES, WHATEVER IT IS ASKED (spec 2026-10-11, 15.3): only Vumasign's own proposals.
692
+ only(args, ['template_id', 'run_id', 'accept'], 'It accepts only the run’s own proposals; a person names a field differently in Vumasign.');
693
+ const templateId = uuid(args, 'template_id');
694
+ const runId = uuid(args, 'run_id');
695
+ return {
696
+ method: 'POST',
697
+ path: `/api/v1/templates/${templateId}/naming-runs/${runId}/accept-current`,
698
+ body: { accept: acceptSelection(args) },
699
+ };
700
+ },
701
+ },
702
+ {
703
+ name: 'accept_person',
704
+ title: 'Accept Vumasign’s proposal for one person',
705
+ description: 'Applies Vumasign’s own current proposal for one person on a template — the decision `get_template_people` ' +
706
+ 'shows as `proposed` — which sets up the subject and signing role it names and moves that person’s fields ' +
707
+ 'to them. It takes the person’s id and nothing else: it cannot choose a different decision. If the proposal ' +
708
+ 'has changed or would change nothing, it is refused; read the people again.',
709
+ inputSchema: {
710
+ type: 'object',
711
+ properties: {
712
+ template_id: uuidProperty('The template’s id.'),
713
+ person_id: { type: 'string', pattern: '^[0-9a-f]{32}$', description: 'The person’s `id`, from `get_template_people`.' },
714
+ },
715
+ required: ['template_id', 'person_id'],
716
+ additionalProperties: false,
717
+ },
718
+ operationId: 'acceptTemplatePersonProposal',
719
+ method: 'POST',
720
+ route: '/api/v1/templates/{templateId}/people/{personId}/accept-proposal',
721
+ scope: 'templates:name',
722
+ returns: 'json',
723
+ carriesFormText: true,
724
+ templateWrite: true,
725
+ uncertainOutcome: 'The request to the Vumasign API failed before an answer came back, so the decision for this person may or may not have been applied. Do not simply retry: read the people with `get_template_people` and look at this person’s `current` first. If it was applied, accepting again is refused as `proposal_changed`.',
726
+ build(args) {
727
+ // ⚠️ NO DECISION OF THE AGENT'S OWN (spec 2026-10-11, 15.3): the server applies its own proposal.
728
+ only(args, ['template_id', 'person_id'], 'It applies Vumasign’s own proposal for the person; a person decides otherwise in Vumasign.');
729
+ const templateId = uuid(args, 'template_id');
730
+ return { method: 'POST', path: `/api/v1/templates/${templateId}/people/${personId(args, 'person_id')}/accept-proposal` };
731
+ },
732
+ },
460
733
  ];
734
+ /** The tools a server started with `config` offers: the template-write tools only behind their flag. */
735
+ export function toolsFor(config) {
736
+ return TOOLS.filter((tool) => tool.templateWrite !== true || config.templateWrites === true);
737
+ }
461
738
  /** The route as a pattern: each `{placeholder}` is one path segment and nothing else. */
462
739
  export function routePattern(route) {
463
740
  const escaped = route
@@ -466,6 +743,7 @@ export function routePattern(route) {
466
743
  .join('[^/]+');
467
744
  return new RegExp(`^${escaped}$`);
468
745
  }
469
- export function findTool(name) {
470
- return TOOLS.find((tool) => tool.name === name);
746
+ /** A tool this server offers, by name: a template-write tool only when `config` allows them. */
747
+ export function findTool(name, config = {}) {
748
+ return toolsFor(config).find((tool) => tool.name === name);
471
749
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vumasign/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for a Vumasign account: read templates and their pages, check values, and create draft envelopes that a person sends. It never sends and never mints a signing URL.",
5
5
  "keywords": [
6
6
  "mcp",