@setluca/mcp 0.5.0 → 0.5.1

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/CHANGELOG.md CHANGED
@@ -10,6 +10,35 @@ input schema, or changing what a tool does to a workspace.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.5.1] - 2026-10-22
14
+
15
+ ### Fixed
16
+
17
+ - Tool annotations now match what each tool does, as the October 2026 audit
18
+ for the OpenAI app directory listed them. All 203 tools it covered match its
19
+ corrected values, and the 21 tools added in 0.5.0 follow the same rules.
20
+ - `idempotentHint` is true for 33 writes that a repeat with the same input
21
+ cannot move further, such as `luca_leads_notes_delete`,
22
+ `luca_insights_dismiss`, `luca_broadcasts_cancel`, and
23
+ `luca_pause_cadence`. It was true only for reads before.
24
+ - `openWorldHint` is true for writes the coach's webhook subscribers hear
25
+ about (`luca_leads_create`, `luca_leads_update`,
26
+ `luca_leads_identities_attach`, `luca_review_queue_reject`,
27
+ `luca_review_queue_bulk_reject`, `luca_broadcasts_pause`) and for the call
28
+ summaries that later reach the CRM (`luca_call_events_summary_generate`,
29
+ `luca_call_events_backfill`). None of them needs `confirm: true`.
30
+ - `luca_leads_import_preview` is read-only. It saves nothing.
31
+ - `luca_integrations_crm_connections_schema` is no longer read-only, because
32
+ it refreshes Luca's cached copy of the CRM schema.
33
+ - `luca_review_queue_objection_variant` is no longer destructive. It adds a
34
+ learning event and leaves the draft as it was.
35
+ - `luca_integrations_crm_sync_runs_create` is titled "Preview a CRM sync" and
36
+ no longer asks for `confirm: true` or claims to reach the CRM. The API
37
+ refuses a live sync, so the tool only records a dry-run preview.
38
+ - `luca_reschedule_call` says that a Cal.com or Google Calendar move waits for
39
+ the provider: until it confirms, the booking reads `pending_provider` and
40
+ keeps its old time.
41
+
13
42
  ## [0.5.0] - 2026-10-22
14
43
 
15
44
  ### Breaking
@@ -10,6 +10,7 @@ export function operationAnnotationSource(operation) {
10
10
  title: operation.title,
11
11
  readOnly: operation.readOnly,
12
12
  mutatesExisting: operation.mutatesExisting,
13
+ idempotent: operation.idempotent,
13
14
  ...optionalField("confirm", operation.confirm),
14
15
  openWorld: operation.openWorld,
15
16
  };
@@ -34,11 +35,11 @@ export function toolAnnotations(source) {
34
35
  // existed before the call.
35
36
  destructiveHint: !source.readOnly &&
36
37
  (source.confirm !== undefined || source.mutatesExisting),
37
- // The idempotency key is optional in the MCP input. The HTTP layer mints a
38
- // new key when it is absent, so two otherwise identical writes can still
39
- // have different effects. Only advertise the unconditional read guarantee
40
- // until the key becomes a required tool argument.
41
- idempotentHint: source.readOnly,
38
+ // Stated per operation: every read, plus the writes a repeat cannot move
39
+ // further, such as a delete, a dismiss, or a whole-value replace. It never
40
+ // leans on the idempotency key, which the HTTP layer mints fresh when the
41
+ // caller leaves it out.
42
+ idempotentHint: source.idempotent,
42
43
  // Every call goes to the Luca API, but most only touch the coach's own
43
44
  // workspace. The hint is true only when the call can reach past it: a
44
45
  // lead's inbox, an outside URL, or a connected calendar or CRM.
@@ -169,6 +169,7 @@ export const bookingsOperations = [
169
169
  op({
170
170
  id: "bookings.guestLinks.revoke",
171
171
  title: "Revoke a booking's guest links",
172
+ idempotent: true,
172
173
  mutatesExisting: true,
173
174
  description: "Kill every outstanding guest link on a booking at once.",
174
175
  }),
@@ -197,11 +198,13 @@ export const bookingsOperations = [
197
198
  op({
198
199
  id: "bookings.types.delete",
199
200
  title: "Delete a booking type",
201
+ idempotent: true,
200
202
  description: "Remove a booking type and get back the types that are left. Calls already booked against it are not cancelled.",
201
203
  }),
202
204
  op({
203
205
  id: "bookings.types.schedule.replace",
204
206
  title: "Replace a booking type schedule",
207
+ idempotent: true,
205
208
  mutatesExisting: true,
206
209
  description: "Replace a booking type's schedule outright. Send every rule and override you want to keep \u2014 anything left out is dropped, which is also how you clear a day. Read the current schedule first unless you mean to start over.",
207
210
  }),
@@ -213,12 +216,14 @@ export const bookingsOperations = [
213
216
  op({
214
217
  id: "bookings.types.attendees.remove",
215
218
  title: "Remove a booking type attendee",
219
+ idempotent: true,
216
220
  mutatesExisting: true,
217
221
  description: "Remove a standing attendee from one booking type and get back the ones that are left. Calls already booked keep the invitees they were created with.",
218
222
  }),
219
223
  op({
220
224
  id: "bookings.calendars.select",
221
225
  title: "Select a booking calendar",
226
+ idempotent: true,
222
227
  mutatesExisting: true,
223
228
  // Selection checks that the calendar exists in the connected Google account.
224
229
  openWorld: true,
@@ -227,6 +232,7 @@ export const bookingsOperations = [
227
232
  op({
228
233
  id: "bookings.calendars.remove",
229
234
  title: "Remove a booking calendar",
235
+ idempotent: true,
230
236
  mutatesExisting: true,
231
237
  description: "Stop using a calendar for conflict checks or as the booking destination. The calendar itself is left alone in Google; only Luca's selection changes.",
232
238
  }),
@@ -238,6 +244,7 @@ export const bookingsOperations = [
238
244
  op({
239
245
  id: "bookings.recovery.stop",
240
246
  title: "Stop a no-show recovery",
247
+ idempotent: true,
241
248
  mutatesExisting: true,
242
249
  description: "Stop a booking's no-show recovery so no further follow-up is scheduled. A follow-up already being sent may still arrive. Send recovery.id as recoveryCaseId and recovery.outcomeVersion from luca_bookings_recovery_get; a recovery that changed since that read returns 409 booking_recovery_changed.",
243
250
  }),
@@ -51,6 +51,9 @@ export const broadcastsOperations = [
51
51
  op({
52
52
  id: "broadcasts.pause",
53
53
  title: "Pause a broadcast",
54
+ idempotent: true,
55
+ // Sends broadcast.paused to the coach's webhook subscribers.
56
+ openWorld: true,
54
57
  mutatesExisting: true,
55
58
  // Recipient snapshot, see the note above this list.
56
59
  untrustedContent: true,
@@ -69,6 +72,7 @@ export const broadcastsOperations = [
69
72
  op({
70
73
  id: "broadcasts.cancel",
71
74
  title: "Cancel a broadcast",
75
+ idempotent: true,
72
76
  mutatesExisting: true,
73
77
  // Recipient snapshot, see the note above this list.
74
78
  untrustedContent: true,
@@ -10,6 +10,7 @@ export const cadencesOperations = [
10
10
  op({
11
11
  id: "cadences.pause",
12
12
  title: "Pause a lead's cadence",
13
+ idempotent: true,
13
14
  mutatesExisting: true,
14
15
  description: "Pause (cancel) a lead's running rescue cadence with an optional reason.",
15
16
  }),
@@ -45,6 +45,8 @@ export const callEventsOperations = [
45
45
  op({
46
46
  id: "callEvents.summary.generate",
47
47
  title: "Generate a call summary",
48
+ // The summary is pushed to the coach's connected CRM after its review window.
49
+ openWorld: true,
48
50
  mutatesExisting: true,
49
51
  untrustedContent: true,
50
52
  description: "Queue a fresh summary and return straight away. Poll the call event for the result.",
@@ -75,6 +77,7 @@ export const callEventsOperations = [
75
77
  op({
76
78
  id: "callEvents.undoOutcome",
77
79
  title: "Undo a call's outcome",
80
+ idempotent: true,
78
81
  mutatesExisting: true,
79
82
  untrustedContent: true,
80
83
  description: "Clear a mistaken attendance mark and put the call back in the queue. The window is 48 hours from when the outcome was set.",
@@ -82,6 +85,9 @@ export const callEventsOperations = [
82
85
  op({
83
86
  id: "callEvents.backfill",
84
87
  title: "Backfill a booking's call event",
88
+ idempotent: true,
89
+ // The summary it queues later syncs to the coach's connected CRM.
90
+ openWorld: true,
85
91
  untrustedContent: true,
86
92
  description: "Create a call event for a booking that predates automatic creation, then queue its summary. Safe to call twice: an existing call event comes back as is.",
87
93
  }),
@@ -53,6 +53,7 @@ export const conversationsOperations = [
53
53
  }),
54
54
  op({
55
55
  id: "conversations.reply_failure_dismiss",
56
+ idempotent: true,
56
57
  // Answers with a flag only; no lead-authored text.
57
58
  untrustedContent: false,
58
59
  title: "Dismiss a failed reply",
@@ -8,18 +8,21 @@ export const insightsOperations = [
8
8
  op({
9
9
  id: "insights.markRead",
10
10
  title: "Mark an insight read",
11
+ idempotent: true,
11
12
  mutatesExisting: true,
12
13
  description: "Mark one insight read, so it stops coming back at the top of the feed.",
13
14
  }),
14
15
  op({
15
16
  id: "insights.dismiss",
16
17
  title: "Dismiss an insight",
18
+ idempotent: true,
17
19
  mutatesExisting: true,
18
20
  description: "Dismiss one insight the coach does not want to act on. Use this rather than leaving it unread forever.",
19
21
  }),
20
22
  op({
21
23
  id: "insights.markActioned",
22
24
  title: "Mark an insight actioned",
25
+ idempotent: true,
23
26
  mutatesExisting: true,
24
27
  description: "Record that the thing an insight asked for was actually done. Use it after doing the work, not instead of it.",
25
28
  }),
@@ -31,6 +34,7 @@ export const insightsOperations = [
31
34
  op({
32
35
  id: "insights.settings.update",
33
36
  title: "Update alert delivery settings",
37
+ idempotent: true,
34
38
  mutatesExisting: true,
35
39
  description: "Merge-patch the coach's alert-delivery settings. Send only what you are changing; anything left out keeps its current value.",
36
40
  }),
@@ -20,12 +20,16 @@ export const integrationsOperations = [
20
20
  op({
21
21
  id: "integrations.crm.connections.create",
22
22
  title: "Create a CRM connection",
23
+ idempotent: true,
23
24
  mutatesExisting: true,
24
25
  description: "Create or update a workspace-scoped CRM connection.",
25
26
  }),
26
27
  op({
27
28
  id: "integrations.crm.connections.schema",
28
29
  title: "Read a CRM's field schema",
30
+ // Refreshes Luca's cached copy of the CRM schema, the same copy every time.
31
+ readOnly: false,
32
+ idempotent: true,
29
33
  openWorld: true,
30
34
  // sampleRecords are literal contact and deal rows from the coach's CRM, which leads and other people filled in.
31
35
  untrustedContent: true,
@@ -48,11 +52,13 @@ export const integrationsOperations = [
48
52
  op({
49
53
  id: "integrations.crm.mappings.suggest",
50
54
  title: "Suggest CRM field mappings",
55
+ idempotent: true,
51
56
  description: "Suggest CRM field mappings.",
52
57
  }),
53
58
  op({
54
59
  id: "integrations.crm.mappings.confirm",
55
60
  title: "Confirm CRM field mappings",
61
+ idempotent: true,
56
62
  mutatesExisting: true,
57
63
  description: "Confirm CRM field mappings.",
58
64
  }),
@@ -65,12 +71,10 @@ export const integrationsOperations = [
65
71
  }),
66
72
  op({
67
73
  id: "integrations.crm.syncRuns.create",
68
- title: "Start a CRM sync",
69
- openWorld: true,
70
- confirm: "always",
74
+ title: "Preview a CRM sync",
71
75
  // Mutations carry CRM record values: names, emails, notes.
72
76
  untrustedContent: true,
73
- description: "Create a CRM dry-run or live sync run.",
77
+ description: "Record a dry-run preview of the changes a CRM sync would make, as a new sync run. Nothing is written to the CRM: the API refuses a live sync.",
74
78
  }),
75
79
  op({
76
80
  id: "integrations.events.list",
@@ -82,6 +86,7 @@ export const integrationsOperations = [
82
86
  op({
83
87
  id: "integrations.crm.connections.disconnect",
84
88
  title: "Disconnect a CRM",
89
+ idempotent: true,
85
90
  mutatesExisting: true,
86
91
  // Stops every sync with that CRM until the coach signs in to it again.
87
92
  confirm: "always",
@@ -15,6 +15,7 @@ export const knowledgeOperations = [
15
15
  op({
16
16
  id: "knowledge.rejectSuggestion",
17
17
  title: "Reject a knowledge suggestion",
18
+ idempotent: true,
18
19
  mutatesExisting: true,
19
20
  description: "Reject a profile suggestion so it stops being offered. The profile is left as it stands.",
20
21
  }),
@@ -33,6 +34,7 @@ export const knowledgeOperations = [
33
34
  op({
34
35
  id: "knowledge.sources.disconnect",
35
36
  title: "Disconnect a knowledge source",
37
+ idempotent: true,
36
38
  mutatesExisting: true,
37
39
  description: "Delete a knowledge source. Its pending suggestions go with it; values the coach already approved stay in the profile, because approving copies them there.",
38
40
  }),
@@ -18,12 +18,17 @@ export const leadsOperations = [
18
18
  op({
19
19
  id: "leads.create",
20
20
  title: "Create or upsert a lead",
21
+ // Sends lead.created to the coach's webhook subscribers.
22
+ openWorld: true,
21
23
  mutatesExisting: true,
22
24
  description: "Create or upsert a lead identity. Body requires channel and externalUserId.",
23
25
  }),
24
26
  op({
25
27
  id: "leads.update",
26
28
  title: "Update a lead",
29
+ // Sends lead.updated to the coach's webhook subscribers, and a tag change
30
+ // can resume a campaign step that messages the lead.
31
+ openWorld: true,
27
32
  description: "Update a lead's display name, stage, score, tags, or custom fields.",
28
33
  }),
29
34
  op({
@@ -33,6 +38,7 @@ export const leadsOperations = [
33
38
  }),
34
39
  op({
35
40
  id: "leads.fieldDefinitions.upsert",
41
+ idempotent: true,
36
42
  // Echoes the coach's own field definition back.
37
43
  untrustedContent: false,
38
44
  title: "Define a custom lead field",
@@ -42,6 +48,8 @@ export const leadsOperations = [
42
48
  op({
43
49
  id: "leads.importPreview",
44
50
  title: "Preview a lead import",
51
+ // A POST only so the CSV fits in a body. It checks the mapping and saves nothing.
52
+ readOnly: true,
45
53
  description: "Preview a mapped CSV lead import before applying it.",
46
54
  }),
47
55
  op({
@@ -55,6 +63,8 @@ export const leadsOperations = [
55
63
  op({
56
64
  id: "leads.identities.attach",
57
65
  title: "Attach a channel identity to a lead",
66
+ // Sends lead.updated to the coach's webhook subscribers.
67
+ openWorld: true,
58
68
  mutatesExisting: true,
59
69
  description: "Attach a verified or claimed identity to a lead.",
60
70
  }),
@@ -71,6 +81,7 @@ export const leadsOperations = [
71
81
  }),
72
82
  op({
73
83
  id: "leads.consents.revoke",
84
+ idempotent: true,
74
85
  // Echoes the consent record the coach revoked.
75
86
  untrustedContent: false,
76
87
  title: "Revoke a lead's consent",
@@ -92,6 +103,7 @@ export const leadsOperations = [
92
103
  }),
93
104
  op({
94
105
  id: "leads.notes.delete",
106
+ idempotent: true,
95
107
  // Answers with ids and status flags only; no lead-authored text.
96
108
  untrustedContent: false,
97
109
  title: "Delete a lead note",
@@ -27,6 +27,7 @@ export const learningOperations = [
27
27
  }),
28
28
  op({
29
29
  id: "learning.proposals.dismiss",
30
+ idempotent: true,
30
31
  // Answers with ids and status flags only; no lead-authored text.
31
32
  untrustedContent: false,
32
33
  title: "Dismiss a learning proposal",
@@ -16,6 +16,7 @@ export const memoryOperations = [
16
16
  }),
17
17
  op({
18
18
  id: "memory.setArchived",
19
+ idempotent: true,
19
20
  // Answers with ids and status flags only; no lead-authored text.
20
21
  untrustedContent: false,
21
22
  title: "Archive or restore memories",
@@ -22,6 +22,8 @@ export const reviewQueueOperations = [
22
22
  }),
23
23
  op({
24
24
  id: "reviewQueue.reject",
25
+ // Sends review.rejected to the coach's webhook subscribers.
26
+ openWorld: true,
25
27
  // Answers with ids and status flags only; no lead-authored text.
26
28
  untrustedContent: false,
27
29
  title: "Reject a queued draft",
@@ -54,7 +56,6 @@ export const reviewQueueOperations = [
54
56
  // Answers with ids and status flags only; no lead-authored text.
55
57
  untrustedContent: false,
56
58
  title: "Record an objection variant choice",
57
- mutatesExisting: true,
58
59
  description: "Record which objection-specialist variant was picked for one queued draft. It is how the objection tests learn which handling works, so record the variant the coach actually chose.",
59
60
  }),
60
61
  op({
@@ -76,6 +77,7 @@ export const reviewQueueOperations = [
76
77
  }),
77
78
  op({
78
79
  id: "reviewQueue.drafting.cancel",
80
+ idempotent: true,
79
81
  // Answers with a turn id and state only; no lead-authored text.
80
82
  untrustedContent: false,
81
83
  title: "Cancel a draft in progress",
@@ -95,6 +97,7 @@ export const reviewQueueOperations = [
95
97
  }),
96
98
  op({
97
99
  id: "reviewQueue.drafting.dismiss",
100
+ idempotent: true,
98
101
  // Answers with no body.
99
102
  untrustedContent: false,
100
103
  title: "Dismiss a stopped draft",
@@ -124,6 +127,9 @@ export const reviewQueueOperations = [
124
127
  }),
125
128
  op({
126
129
  id: "reviewQueue.bulkReject",
130
+ idempotent: true,
131
+ // Sends review.rejected for each draft to the coach's webhook subscribers.
132
+ openWorld: true,
127
133
  // Answers with one outcome per id; no lead-authored text.
128
134
  untrustedContent: false,
129
135
  title: "Reject several drafts",
@@ -29,6 +29,7 @@ export const webhooksOperations = [
29
29
  op({
30
30
  id: "webhooks.subscriptions.delete",
31
31
  title: "Delete a webhook subscription",
32
+ idempotent: true,
32
33
  // Deleting by id is naturally idempotent (the route matches its policy
33
34
  // entry, which carries no requireIdempotency), so no Idempotency-Key is
34
35
  // sent and it stays out of the confirm-gated tier that re-triggers live
@@ -286,6 +286,7 @@ export function op(input) {
286
286
  const pageContract = pageContractFor(method, path);
287
287
  const group = operationGroupForId(input.id);
288
288
  const untrustedContent = input.untrustedContent ?? HashSet.has(UNTRUSTED_CONTENT_GROUPS, group);
289
+ const readOnly = input.readOnly ?? method === "GET";
289
290
  const operation = {
290
291
  id: input.id,
291
292
  group,
@@ -295,7 +296,8 @@ export function op(input) {
295
296
  path,
296
297
  scopes,
297
298
  mutatesExisting: mutatesExistingRoute,
298
- readOnly: input.readOnly ?? method === "GET",
299
+ readOnly,
300
+ idempotent: input.idempotent ?? readOnly,
299
301
  idempotencyRequired,
300
302
  ...optionalField("confirm", input.confirm),
301
303
  openWorld: input.openWorld ?? false,
@@ -239,7 +239,7 @@ const DEFAULT_GUEST_LINK_HOURS = 168;
239
239
  const rescheduleCall = taskTool({
240
240
  name: "luca_reschedule_call",
241
241
  title: "Move a call to a new time",
242
- description: 'Move a booked call to a new time and hand back a fresh reschedule link for the lead. Checks the new slot is open first, against the schedule of its booking type when it has one, and returns the open slots instead of moving the call when it is taken. A call with an idempotencyKey skips that check so a retry gets the original result back. A manual booking moves in Luca only. A Cal.com or Google Calendar booking moves on that calendar too. A Calendly, GoHighLevel or iClosed booking is refused so the coach can move it with the provider, and so is a provider booking with no calendar event, such as one recorded with luca_bookings_create. Moving the call re-arms the lead\'s reminder for the new time, so pass confirm: true only after the coach approves the move. Example: call with { bookingId, slotAt: "2026-07-16T15:00:00Z", confirm: true } after the lead asked to push the call. No message about the move is sent: send the lead the link yourself, or draft the message through the review queue.',
242
+ description: "Move a booked call to a new time and hand back a fresh reschedule link for the lead. Checks the new slot is open first, against the schedule of its booking type when it has one, and returns the open slots instead of moving the call when it is taken. A call with an idempotencyKey skips that check so a retry gets the original result back. A manual booking moves in Luca only. A Cal.com or Google Calendar booking moves on that calendar too, but only once the provider confirms: until then the booking's operation.state reads pending_provider and it keeps its old time, so read the booking again before telling the coach the call moved. A Calendly, GoHighLevel or iClosed booking is refused so the coach can move it with the provider, and so is a provider booking with no calendar event, such as one recorded with luca_bookings_create. Moving the call re-arms the lead's reminder for the new time, so pass confirm: true only after the coach approves the move. Example: call with { bookingId, slotAt: \"2026-07-16T15:00:00Z\", confirm: true } after the lead asked to push the call. No message about the move is sent: send the lead the link yourself, or draft the message through the review queue.",
243
243
  inputSchema: {
244
244
  ...WORKSPACE_INPUT_FIELDS,
245
245
  bookingId: NonEmptyString.annotate({ description: "The booking to move." }),
package/dist/tool.js CHANGED
@@ -95,6 +95,9 @@ function buildFromTaskTool(tool) {
95
95
  // A task tool overwrites or removes existing state when any write it
96
96
  // composes does.
97
97
  mutatesExisting: composed.some((operation) => operation.mutatesExisting),
98
+ // Repeating a task tool has no further effect only when that holds for
99
+ // every operation it composes.
100
+ idempotent: composed.every((operation) => operation.idempotent),
98
101
  ...optionalField("confirm", confirm),
99
102
  untrustedContent,
100
103
  // A task tool reaches as far as the furthest operation it composes.
package/dist/version.js CHANGED
@@ -9,6 +9,6 @@
9
9
  *
10
10
  * Bump this in the same commit as package.json and server.json.
11
11
  */
12
- export const LUCA_MCP_VERSION = "0.5.0";
12
+ export const LUCA_MCP_VERSION = "0.5.1";
13
13
  /** The user-agent every outbound Luca API call identifies itself with. */
14
14
  export const LUCA_MCP_USER_AGENT = `luca-mcp/${LUCA_MCP_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@setluca/mcp",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "private": false,
5
5
  "description": "Connect SetLuca to Claude, ChatGPT, Cursor, or any MCP client to triage the review queue, look up leads, draft replies, and check campaigns.",
6
6
  "keywords": [