@setluca/mcp 0.3.1 → 0.3.3

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,34 @@ input schema, or changing what a tool does to a workspace.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.3.3] - 2026-10-06
14
+
15
+ ### Fixed
16
+
17
+ - Mark live booking provider and calendar reads, plus call feedback that can
18
+ start no-show recovery, as reaching outside the Luca workspace in MCP tool
19
+ annotations.
20
+ - Mark booking outcomes that can queue CRM deal-stage updates as reaching
21
+ outside Luca, calendar selection as reading Google, and CRM OAuth URL creation
22
+ as a state-changing GET. State-changing GETs no longer retry without an
23
+ idempotency key.
24
+ - Require confirmation for won or lost booking outcomes, directly and through
25
+ the call-closing task tool, before either can queue a connected CRM deal update.
26
+ Existing calls without `confirm: true` now receive `confirmation_required`;
27
+ this safety change is intentionally included in 0.3.3 despite the normal
28
+ major-version rule for a changed tool-call contract.
29
+ - Keep unexpected tool and transport error messages out of client results and
30
+ Worker logs.
31
+
32
+ ## [0.3.2] - 2026-10-06
33
+
34
+ ### Fixed
35
+
36
+ - Correct MCP safety hints for CRM schema and health probes, knowledge-source
37
+ syncs, and the settings read that creates defaults on first use.
38
+ - Clarify the display names and descriptions of the ghosted-lead analytics and
39
+ weekly business report tools without changing their stable tool names.
40
+
13
41
  ## [0.3.1] - 2026-10-06
14
42
 
15
43
  ### Fixed
@@ -8,7 +8,7 @@ import { optionalField } from "./optional-field.js";
8
8
  export function operationAnnotationSource(operation) {
9
9
  return {
10
10
  title: operation.title,
11
- readOnly: operation.method === "GET",
11
+ readOnly: operation.readOnly,
12
12
  mutatesExisting: operation.mutatesExisting,
13
13
  ...optionalField("confirm", operation.confirm),
14
14
  openWorld: operation.openWorld,
@@ -36,8 +36,8 @@ export const analyticsOperations = [
36
36
  }),
37
37
  op({
38
38
  id: "analytics.ghostedLeads",
39
- title: "List ghosted leads",
40
- description: "Ghosted-lead counts, rescue-recovery rate, lost-revenue estimate, and top conversation-killer clusters for a period.",
39
+ title: "Analyze leads who stopped replying",
40
+ description: "Summarize leads who stopped replying during a period, including their count, follow-up recovery rate, estimated lost revenue, and common reasons conversations stalled. This is an aggregate report, not a list of individual leads.",
41
41
  // Each top-killer cluster label is free text drawn from lead messages.
42
42
  untrustedContent: true,
43
43
  }),
@@ -1,3 +1,4 @@
1
+ import { isJsonObject } from "../../serialization.js";
1
2
  import { op } from "../registry.js";
2
3
  export const bookingsOperations = [
3
4
  op({
@@ -29,11 +30,15 @@ export const bookingsOperations = [
29
30
  op({
30
31
  id: "bookings.availability",
31
32
  title: "Read open call slots",
33
+ // Availability comes from the connected booking provider.
34
+ openWorld: true,
32
35
  description: "List open slots the coach can actually be booked into. Check this before booking.",
33
36
  }),
34
37
  op({
35
38
  id: "bookings.managedAvailability",
36
39
  title: "Read Luca-managed call slots",
40
+ // Google Calendar removes conflicts from Luca-managed slots.
41
+ openWorld: true,
37
42
  description: "List open slots for one of Luca's own call types, with calendar conflicts already removed.",
38
43
  }),
39
44
  op({
@@ -59,6 +64,8 @@ export const bookingsOperations = [
59
64
  op({
60
65
  id: "bookings.providers.get",
61
66
  title: "Get a booking provider",
67
+ // Event types and calendars are fetched from the provider account.
68
+ openWorld: true,
62
69
  description: "List the event types and calendars in one connected provider account.",
63
70
  }),
64
71
  op({
@@ -127,7 +134,14 @@ export const bookingsOperations = [
127
134
  id: "bookings.outcome.record",
128
135
  title: "Record a booking's outcome",
129
136
  mutatesExisting: true,
130
- description: "Record what the call was worth: won, lost with a reason, or still open. To correct an earlier outcome send the expectedVersion you read; a stale version returns 409 with the current one.",
137
+ // Won and lost outcomes queue a deal-stage change in the connected CRM.
138
+ openWorld: true,
139
+ confirm: {
140
+ applies: (input) => isJsonObject(input.body) &&
141
+ (input.body.status === "won" || input.body.status === "lost"),
142
+ description: "status is won or lost (can update a connected CRM deal)",
143
+ },
144
+ description: "Record what the call was worth: won, lost with a reason, or still open. Won and lost outcomes can update a connected CRM deal stage, so pass confirm: true for those outcomes. To correct an earlier outcome send the expectedVersion you read; a stale version returns 409 with the current one.",
131
145
  }),
132
146
  op({
133
147
  id: "bookings.lifecycleResolution",
@@ -206,6 +220,8 @@ export const bookingsOperations = [
206
220
  id: "bookings.calendars.select",
207
221
  title: "Select a booking calendar",
208
222
  mutatesExisting: true,
223
+ // Selection checks that the calendar exists in the connected Google account.
224
+ openWorld: true,
209
225
  description: "Add or update a calendar selection, either for conflict checks or as the destination new bookings are written to. The destination has to be writable: a read-only calendar returns 400, and a Google connection needing attention returns 409.",
210
226
  }),
211
227
  op({
@@ -27,6 +27,8 @@ export const callEventsOperations = [
27
27
  op({
28
28
  id: "callEvents.feedback",
29
29
  title: "Report how a call went",
30
+ // A no-show report can start recovery messaging to the lead.
31
+ openWorld: true,
30
32
  mutatesExisting: true,
31
33
  untrustedContent: true,
32
34
  // Only a no_show can start recovery that messages the lead, so only that
@@ -26,6 +26,7 @@ export const insightsOperations = [
26
26
  op({
27
27
  id: "insights.settings.get",
28
28
  title: "Read alert delivery settings",
29
+ readOnly: false,
29
30
  description: "Read which insight alerts reach the coach and how. The row is created with defaults on the first read, so this never 404s. Check it before concluding a quiet feed means nothing is happening \u2014 it may just be muted.",
30
31
  }),
31
32
  op({
@@ -8,7 +8,9 @@ export const integrationsOperations = [
8
8
  op({
9
9
  id: "integrations.crm.oauthUrl",
10
10
  title: "Start a CRM connection",
11
- description: "Create a CRM provider OAuth URL.",
11
+ // This GET stores a fresh OAuth state record for the callback.
12
+ readOnly: false,
13
+ description: "Create a CRM provider OAuth URL and store its one-time state.",
12
14
  }),
13
15
  op({
14
16
  id: "integrations.crm.connections.list",
@@ -24,6 +26,7 @@ export const integrationsOperations = [
24
26
  op({
25
27
  id: "integrations.crm.connections.schema",
26
28
  title: "Read a CRM's field schema",
29
+ openWorld: true,
27
30
  // sampleRecords are literal contact and deal rows from the coach's CRM, which leads and other people filled in.
28
31
  untrustedContent: true,
29
32
  description: "Discover CRM provider schema.",
@@ -31,6 +34,7 @@ export const integrationsOperations = [
31
34
  op({
32
35
  id: "integrations.crm.connections.health",
33
36
  title: "Check a CRM connection's health",
37
+ openWorld: true,
34
38
  description: "Probe CRM provider health.",
35
39
  }),
36
40
  op({
@@ -27,6 +27,7 @@ export const knowledgeOperations = [
27
27
  id: "knowledge.sources.sync",
28
28
  title: "Re-sync a knowledge source",
29
29
  mutatesExisting: true,
30
+ openWorld: true,
30
31
  description: "Queue a re-read of one knowledge source now rather than waiting for its next scheduled sync. It returns as soon as the job is queued, so any new suggestions appear on a later read.",
31
32
  }),
32
33
  op({
@@ -291,6 +291,7 @@ export function op(input) {
291
291
  path,
292
292
  scopes,
293
293
  mutatesExisting: mutatesExistingRoute,
294
+ readOnly: input.readOnly ?? method === "GET",
294
295
  idempotencyRequired,
295
296
  ...optionalField("confirm", input.confirm),
296
297
  openWorld: input.openWorld ?? false,
@@ -8,16 +8,14 @@ import * as Schedule from "effect/Schedule";
8
8
  import * as Str from "effect/String";
9
9
  import { formatError, LucaHttpError, LucaTimeoutError, } from "./errors.js";
10
10
  /**
11
- * The statuses worth sending again. All four mean the request never reached the
12
- * handler, so a replay cannot repeat an effect the first attempt already had.
11
+ * The statuses worth considering for a retry. A gateway can answer after the
12
+ * handler ran, so a replay is still subject to read-only or idempotency rules.
13
13
  *
14
14
  * 500 is deliberately not here. A 500 means the handler ran and threw, which
15
15
  * may well be after it wrote something. Replaying that without an idempotency
16
16
  * key can double the write.
17
17
  */
18
18
  const RETRYABLE_STATUSES = HashSet.fromIterable([429, 502, 503, 504]);
19
- /** Methods with no effect to repeat, so a replay is safe with or without a key. */
20
- const REPLAYABLE_METHODS = HashSet.fromIterable(["GET", "HEAD"]);
21
19
  /**
22
20
  * The longest server-requested wait we will sit through. Past this the server
23
21
  * is not asking for a retry, it is asking us to come back later. Holding a
@@ -74,7 +72,7 @@ function isTransient(error) {
74
72
  : HashSet.has(TRANSIENT_TAGS, error._tag);
75
73
  }
76
74
  /**
77
- * Whether this failure may be sent again. Reads are always replayable. A write
75
+ * Whether this failure may be sent again. Read-only calls are replayable. A write
78
76
  * is replayable only when it carries an idempotency key, because the API dedupes
79
77
  * by [organizationId, coachId, actorId, method, path, key] and that is the only
80
78
  * replay which cannot file the same write twice.
@@ -89,8 +87,7 @@ export function mayRetry(policy, error) {
89
87
  if (Option.exists(requestedDelay(error), (ms) => ms > MAX_RETRY_AFTER_MS)) {
90
88
  return false;
91
89
  }
92
- return (HashSet.has(REPLAYABLE_METHODS, policy.method) ||
93
- policy.idempotencyKey !== undefined);
90
+ return policy.readOnly || policy.idempotencyKey !== undefined;
94
91
  }
95
92
  /**
96
93
  * The backoff schedule with a `Retry-After` folded in: the next replay waits
@@ -124,13 +121,13 @@ function lastFailureNote(lastFailure) {
124
121
  });
125
122
  }
126
123
  /**
127
- * The timeout a caller reads. A read can be sent again. A write that timed out
128
- * may or may not have landed, so the message says so and names the key that
129
- * makes a second send safe.
124
+ * The timeout a caller reads. A read-only call can be sent again. A call that
125
+ * changes state and timed out may or may not have landed, so the message says
126
+ * so and names the key that makes a second send safe when there is one.
130
127
  */
131
128
  function timeoutError(policy, ms, lastFailure = Option.none()) {
132
129
  const base = `Luca API did not answer within ${ms}ms${lastFailureNote(lastFailure)}`;
133
- if (HashSet.has(REPLAYABLE_METHODS, policy.method)) {
130
+ if (policy.readOnly) {
134
131
  return new LucaTimeoutError({ message: base });
135
132
  }
136
133
  const replay = policy.idempotencyKey === undefined
@@ -163,6 +160,7 @@ export function resilient(request, timeoutMs, attempt, deadlineMs = CALL_DEADLIN
163
160
  const lastFailure = yield* Ref.make(Option.none());
164
161
  const policy = {
165
162
  method: request.operation.method,
163
+ readOnly: request.operation.readOnly,
166
164
  idempotencyKey,
167
165
  };
168
166
  return yield* attempt(idempotencyKey).pipe(Effect.timeoutOrElse({
@@ -177,8 +177,8 @@ const getAnalytics = taskTool({
177
177
  });
178
178
  const magicMonday = taskTool({
179
179
  name: "luca_analytics_magic_monday",
180
- title: "Get the Magic Monday report",
181
- description: "Return the coach's weekly business intelligence report: pipeline funnel, revenue and ROI, response-speed impact, a point-in-time revenue forecast, and call intelligence (show/close rates), all for the current week. Composes five analytics reads into a single call. Example: call with {} on Monday morning for the week's report.",
180
+ title: "Read the weekly business report",
181
+ description: "Return the current week's business report (called Magic Monday in Luca): pipeline funnel, revenue and ROI, response-speed impact, a point-in-time revenue forecast, and call intelligence (show/close rates). Composes five analytics reads into a single call. Example: call with {} for the weekly report.",
182
182
  inputSchema: {
183
183
  ...WORKSPACE_INPUT_FIELDS,
184
184
  },
@@ -91,12 +91,13 @@ function outcomeBody(input) {
91
91
  const closeCallLoop = taskTool({
92
92
  name: "luca_close_call_loop",
93
93
  title: "Close the loop on a call",
94
- description: 'File what happened on a call and what it was worth, in one call. Reports attendance on the call event (completed, no_show, rescheduled, or cancelled) and, when an outcome is given, records the booking outcome: won with an amount, lost with a reason id from luca_bookings_outcome_reasons_list, or open with the next follow-up. This tool sends no message to the lead itself, but the records it files start work in the API. Reporting a no_show can start no-show recovery, which schedules a message to the lead when the booking type has recovery on, so pass confirm: true only after the coach says to report the no-show. A completed call queues its summary, which can later sync to the connected CRM. A won or lost outcome queues a CRM deal-stage update, and lost also moves the lead to lost. Example: { bookingId, attendance: "completed", outcome: "won", amountMinor: 250000, currency: "USD" }.',
95
- // Only a no_show can message the lead (through recovery), so only that
96
- // report needs approval. The other attendance values stay ungated.
94
+ description: 'File what happened on a call and what it was worth, in one call. Reports attendance on the call event (completed, no_show, rescheduled, or cancelled) and, when an outcome is given, records the booking outcome: won with an amount, lost with a reason id from luca_bookings_outcome_reasons_list, or open with the next follow-up. This tool sends no message to the lead itself, but the records it files start work in the API. Reporting a no_show can start no-show recovery, which schedules a message to the lead when the booking type has recovery on. A completed call queues its summary, which can later sync to the connected CRM. A won or lost outcome queues a CRM deal-stage update, and lost also moves the lead to lost. Pass confirm: true before reporting a no_show or recording a won or lost outcome. Example: { bookingId, attendance: "completed", outcome: "won", amountMinor: 250000, currency: "USD", confirm: true }.',
95
+ // Recovery messaging and CRM deal updates are the two external effects.
97
96
  confirm: {
98
- applies: (input) => input.attendance === "no_show",
99
- description: "attendance is no_show (can start recovery messaging)",
97
+ applies: (input) => input.attendance === "no_show" ||
98
+ input.outcome === "won" ||
99
+ input.outcome === "lost",
100
+ description: "attendance is no_show or outcome is won or lost (can message a lead or update a connected CRM deal)",
100
101
  },
101
102
  inputSchema: {
102
103
  ...WORKSPACE_INPUT_FIELDS,
package/dist/tool.js CHANGED
@@ -91,7 +91,7 @@ function buildFromTaskTool(tool) {
91
91
  ...openApiToolOutputFields({ body: null, untrustedContent }),
92
92
  result: tool.outputSchema,
93
93
  },
94
- readOnly: composed.every((operation) => operation.method === "GET"),
94
+ readOnly: composed.every((operation) => operation.readOnly),
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),
@@ -144,9 +144,8 @@ function toolResult(tool, lucaLayer, surface,
144
144
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- input is the same raw MCP JSON-RPC tool-call arguments registerTool's server.registerTool callback hands in; parseToolInput below is what actually validates it.
145
145
  input) {
146
146
  const logCall = (outcome, elapsed) =>
147
- // A defect's own message never lands here: it can carry arbitrary thrown
148
- // text derived from the call's input. The client still sees it in the
149
- // tool result.
147
+ // A defect's own message never lands here or in the tool result: it can
148
+ // carry arbitrary thrown text derived from the call's input.
150
149
  logAgentSurfaceEvent("mcp.tool.called", {
151
150
  toolName: tool.name,
152
151
  toolset: surface.toolset,
@@ -184,12 +183,11 @@ function settle(tool, surface, exit) {
184
183
  asErrorResult(failure, authChallengeMeta(failure, tool.oauthScope, surface.resourceMetadataUrl)),
185
184
  ];
186
185
  }
187
- const defect = Cause.squash(cause);
188
186
  return [
189
187
  "defect",
190
- asErrorResult(
191
- // oxlint-disable-next-line effect/avoid-untagged-errors -- boundary narrows an unknown thrown value that carries no tag
192
- defect instanceof Error ? defect : new Error(String(defect))),
188
+ // A defect may include request data or credentials in its message. Keep
189
+ // the protocol error generic and record only the defect outcome in logs.
190
+ asErrorResult("Unexpected Luca MCP tool failure"),
193
191
  ];
194
192
  }
195
193
  /** The Effect program that runs an operation, auto-paginating list tools. */
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.3.1";
12
+ export const LUCA_MCP_VERSION = "0.3.3";
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.3.1",
3
+ "version": "0.3.3",
4
4
  "private": false,
5
5
  "description": "Let your AI assistant help with Luca conversations, leads, replies, campaigns, and bookings.",
6
6
  "keywords": [