@setluca/mcp 0.4.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/LICENSE +202 -21
  3. package/README.md +13 -11
  4. package/dist/annotations.js +6 -5
  5. package/dist/generated/openapi-inputs.js +44 -23
  6. package/dist/generated/openapi-outputs.js +4895 -684
  7. package/dist/generated/route-catalog.js +168 -0
  8. package/dist/landing.js +97 -0
  9. package/dist/operations/groups/bookings.js +31 -0
  10. package/dist/operations/groups/broadcasts.js +4 -0
  11. package/dist/operations/groups/cadences.js +1 -0
  12. package/dist/operations/groups/callEvents.js +15 -1
  13. package/dist/operations/groups/channels.js +13 -0
  14. package/dist/operations/groups/conversations.js +34 -0
  15. package/dist/operations/groups/insights.js +5 -2
  16. package/dist/operations/groups/integrations.js +17 -4
  17. package/dist/operations/groups/knowledge.js +2 -0
  18. package/dist/operations/groups/leads.js +36 -1
  19. package/dist/operations/groups/learning.js +1 -0
  20. package/dist/operations/groups/memory.js +1 -0
  21. package/dist/operations/groups/reviewQueue.js +80 -1
  22. package/dist/operations/groups/webhooks.js +1 -0
  23. package/dist/operations/registry.js +11 -4
  24. package/dist/prompts.js +2 -2
  25. package/dist/remote.js +6 -0
  26. package/dist/resources.js +1 -1
  27. package/dist/scopes.js +13 -11
  28. package/dist/server.js +5 -3
  29. package/dist/task-tools/analytics.js +1 -1
  30. package/dist/task-tools/bookings.js +112 -44
  31. package/dist/task-tools/calls.js +1 -1
  32. package/dist/task-tools/inbox.js +1 -1
  33. package/dist/task-tools/leads.js +1 -1
  34. package/dist/tool.js +3 -0
  35. package/dist/version.js +1 -1
  36. package/package.json +5 -3
@@ -1,8 +1,11 @@
1
1
  import * as Effect from "effect/Effect";
2
2
  import * as Option from "effect/Option";
3
+ import * as P from "effect/Predicate";
3
4
  import * as Schema from "effect/Schema";
4
- import { IsoDateTime, NonEmptyString, WORKSPACE_INPUT_FIELDS, } from "../fields.js";
5
+ import { LucaDecodeError } from "../errors.js";
6
+ import { IanaTimeZone, IsoDateTime, NonEmptyString, WORKSPACE_INPUT_FIELDS, } from "../fields.js";
5
7
  import { optionalField } from "../optional-field.js";
8
+ import { toJsonPayload } from "../serialization.js";
6
9
  import { failureAsField, idempotencyKeyInput, isPartialFailure, numberInput, operationResult, resultOrError, stringInput, taskTool, } from "./runtime.js";
7
10
  /** The providers the API accepts on an availability read and on a booking. */
8
11
  const BOOKING_PROVIDERS = [
@@ -29,10 +32,16 @@ const AvailabilityPage = Schema.Struct({
29
32
  slots: Schema.Array(Schema.Struct({ startsAt: Schema.String })),
30
33
  });
31
34
  const decodeAvailabilityPage = Schema.decodeUnknownOption(AvailabilityPage);
32
- const ManagedAvailabilityPage = Schema.Struct({
33
- slots: Schema.Array(Schema.Struct({ startsAt: Schema.String })),
35
+ /** A slot from Luca's own scheduler, as the provider booking route takes it. */
36
+ const ManagedSlotPage = Schema.Struct({
37
+ slots: Schema.Array(Schema.Struct({
38
+ id: Schema.String,
39
+ startsAt: Schema.String,
40
+ endsAt: Schema.String,
41
+ timezone: Schema.String,
42
+ })),
34
43
  });
35
- const decodeManagedAvailabilityPage = Schema.decodeUnknownOption(ManagedAvailabilityPage);
44
+ const decodeManagedSlotPage = Schema.decodeUnknownOption(ManagedSlotPage);
36
45
  const BookingBookingType = Schema.Struct({
37
46
  booking: Schema.Struct({
38
47
  bookingTypeId: Schema.optionalKey(Schema.NullOr(Schema.String)),
@@ -50,14 +59,21 @@ const providerSlots = (call, input, window) => call("bookings.availability", {
50
59
  query: { ...window, ...optionalField("provider", input.provider) },
51
60
  }).pipe(Effect.map((page) => decodeAvailabilityPage(page).pipe(Option.filter((parsed) => parsed.source === "live_provider"), Option.map((parsed) => parsed.slots))));
52
61
  /**
53
- * Open slots for one booking type, from Luca's own scheduler. The provider
54
- * route knows nothing of booking types, so it would judge the slot against
55
- * the wrong calendar. Times are compared as instants, so the display timezone
56
- * does not matter.
62
+ * Open slots for one booking type in a window, from Luca's own scheduler,
63
+ * which already takes the coach's calendar conflicts out. The provider route
64
+ * knows nothing of booking types, so it would judge a slot against the wrong
65
+ * calendar.
66
+ *
67
+ * An answer this cannot read fails as a decode error instead of reading as no
68
+ * slots, so a changed response is never mistaken for a full calendar.
57
69
  */
58
- const bookingTypeSlots = (call, bookingTypeId, window) => call("bookings.managedAvailability", {
59
- query: { ...window, bookingTypeId, timezone: "UTC" },
60
- }).pipe(Effect.map((page) => decodeManagedAvailabilityPage(page).pipe(Option.map((parsed) => parsed.slots))));
70
+ const managedSlots = (call, query) => call("bookings.managedAvailability", { query }).pipe(Effect.flatMap((page) => Option.match(decodeManagedSlotPage(page), {
71
+ onNone: () => Effect.fail(new LucaDecodeError({
72
+ message: "Luca API returned managed availability in a shape this server cannot read",
73
+ body: toJsonPayload(page),
74
+ })),
75
+ onSome: (parsed) => Effect.succeed(parsed.slots),
76
+ })));
61
77
  /**
62
78
  * The booking type a booking was made under, read from the booking itself.
63
79
  *
@@ -115,60 +131,107 @@ function slotNotAvailable(key) {
115
131
  openSlots: Schema.Array(Schema.String),
116
132
  });
117
133
  }
134
+ const SlotTakenBody = Schema.Struct({
135
+ error: Schema.Struct({ code: Schema.Literal("slot_unavailable") }),
136
+ });
137
+ const isSlotTakenBody = Schema.is(SlotTakenBody);
138
+ /**
139
+ * The provider booking route's answer when the slot went between the
140
+ * availability read and the booking: a 409 whose code names it.
141
+ */
142
+ function isSlotTaken(error) {
143
+ return (P.isTagged(error, "LucaHttpError") &&
144
+ error.status === 409 &&
145
+ isSlotTakenBody(error.body));
146
+ }
118
147
  const bookCall = taskTool({
119
148
  name: "luca_book_call",
120
149
  title: "Book a call with a lead",
121
- description: "Record a booked call for a lead in Luca. This does not create an event on the coach's calendar or send the lead an invite: book the call with the provider too, or have the lead book through the coach's booking link. Checks availability first, against the schedule of the booking type when bookingTypeId is given, and returns the open slots instead of booking when the requested one is taken. A call with an idempotencyKey skips that check so a retry gets the original result back. Booking moves the lead to booked and notifies the coach's webhook subscribers, so pass confirm: true only after the coach approves this exact slot. Example: call with { leadId, slotAt: \"2026-07-14T15:00:00Z\", confirm: true } after the lead agreed to a time.",
150
+ description: "Book a call on the coach's calendar provider and have the provider invite the lead's email; the lead also gets Luca's booking confirmation. Works for call types on Calendly, Cal.com, or Google Calendar. Reads the open slots of the call type first and books the one that starts at slotAt. When that slot is taken it books nothing and returns the open slots instead, so pick one of them and call again. The answer is usually pending while the provider confirms; check it with luca_bookings_provider_requests_get. The booking puts an event on the coach's calendar and invites the lead, so pass confirm: true only after the coach approves this exact slot. If a retry answers slot_not_available, the first attempt may have taken the slot, so check luca_bookings_list before booking again. To record a call that is already on a calendar, use luca_bookings_create instead. Example: call with { leadId, bookingTypeId, slotAt: \"2026-07-14T15:00:00Z\", inviteeEmail: \"sam@example.com\", confirm: true } after the lead agreed to a time.",
122
151
  inputSchema: {
123
152
  ...WORKSPACE_INPUT_FIELDS,
124
153
  leadId: NonEmptyString.annotate({ description: "The lead to book." }),
154
+ bookingTypeId: NonEmptyString.annotate({
155
+ description: "The call type to book, from luca_bookings_types_list. Its provider is the calendar the call lands on.",
156
+ }),
125
157
  slotAt: IsoDateTime.annotate({
126
- description: "ISO-8601 start time for the call, with an offset.",
158
+ description: "ISO-8601 start time for the call, with an offset. It must be an open slot of the call type, as luca_bookings_managed_availability lists them.",
127
159
  }),
128
- provider: Schema.optionalKey(Schema.Literals(BOOKING_PROVIDERS).annotate({
129
- description: "The coach's booking provider. Sent with the booking and used for the availability check. Pass it when the coach is not on Calendly, or the availability check reads the wrong calendar. Left off, the check reads Calendly and the API records the booking as manual.",
160
+ inviteeEmail: NonEmptyString.annotate({
161
+ description: "The lead's email. The provider sends the invite there.",
162
+ }),
163
+ inviteeName: Schema.optionalKey(NonEmptyString.check(Schema.isMaxLength(200)).annotate({
164
+ description: "The lead's name as the invite should show it.",
130
165
  })),
131
- bookingTypeId: Schema.optionalKey(NonEmptyString.annotate({
132
- description: "The booking type to book under, from luca_bookings_types_list. Required when provider is google_calendar, and it must be an active google_calendar type.",
166
+ timezone: Schema.optionalKey(IanaTimeZone.annotate({
167
+ description: "IANA time zone the booking is shown in, such as Europe/Madrid. Defaults to UTC.",
133
168
  })),
134
- ...idempotencyKeyInput("Reuse the same key when retrying this exact booking. A keyed call skips the availability check and lets the API replay the first result."),
169
+ ...idempotencyKeyInput("Reuse the same key when retrying this exact booking, so a repeat of a request that already reached the API cannot book the call twice."),
135
170
  },
136
171
  outputSchema: Schema.Union([
137
172
  slotNotAvailable("booked"),
138
- operationResult("bookings.create"),
173
+ operationResult("bookings.providerRequests.create"),
139
174
  ]),
140
175
  composes: [
141
- "bookings.availability",
142
176
  "bookings.managedAvailability",
143
- "bookings.create",
177
+ "bookings.providerRequests.create",
144
178
  ],
145
179
  run: (call, input) => Effect.gen(function* () {
146
180
  const slotAt = stringInput(input.slotAt);
147
- // A booking type has its own schedule, which the provider route knows
148
- // nothing of.
149
- const open = yield* openSlotsAt(input, slotAt, (window) => input.bookingTypeId === undefined
150
- ? providerSlots(call, input, window)
151
- : bookingTypeSlots(call, stringInput(input.bookingTypeId), window));
152
- // Refuse only on a confident "that slot is not open". Handing back the
153
- // real slots lets the agent pick again in one turn instead of writing a
154
- // booking the provider will reject later.
155
- if (Option.isSome(open) &&
156
- !open.value.some((candidate) => sameInstant(candidate, slotAt))) {
157
- return {
158
- booked: false,
159
- reason: "slot_not_available",
160
- requestedSlotAt: slotAt,
161
- openSlots: open.value,
162
- };
181
+ const bookingTypeId = stringInput(input.bookingTypeId);
182
+ const timezone = input.timezone === undefined ? "UTC" : stringInput(input.timezone);
183
+ // oxlint-disable-next-line effect/use-clock-service -- parse date value from stored data
184
+ const start = new Date(slotAt);
185
+ // The provider route books only a slot the scheduler offers, so its id
186
+ // and end come from these reads. A failed read fails the tool: there is
187
+ // nothing to book without it.
188
+ const slots = yield* managedSlots(call, {
189
+ bookingTypeId,
190
+ timezone,
191
+ start: start.toISOString(),
192
+ // oxlint-disable-next-line effect/use-clock-service -- parse date value from stored data
193
+ end: new Date(start.getTime() + AVAILABILITY_WINDOW_MS).toISOString(),
194
+ });
195
+ const found = slots.find((candidate) => sameInstant(candidate.startsAt, slotAt));
196
+ const taken = {
197
+ booked: false,
198
+ reason: "slot_not_available",
199
+ requestedSlotAt: slotAt,
200
+ openSlots: slots
201
+ .filter((candidate) => candidate !== found)
202
+ .map((candidate) => candidate.startsAt),
203
+ };
204
+ if (found === undefined) {
205
+ return taken;
163
206
  }
164
- return yield* call("bookings.create", {
207
+ // A slot id carries a version of everything the read saw in its window,
208
+ // and the provider route checks it against a read of the slot's own
209
+ // window. Read that window too, so the id is the one the route expects.
210
+ const exact = (yield* managedSlots(call, {
211
+ bookingTypeId,
212
+ timezone,
213
+ start: found.startsAt,
214
+ end: found.endsAt,
215
+ })).find((candidate) => sameInstant(candidate.startsAt, found.startsAt) &&
216
+ sameInstant(candidate.endsAt, found.endsAt));
217
+ if (exact === undefined) {
218
+ return taken;
219
+ }
220
+ return yield* call("bookings.providerRequests.create", {
165
221
  body: {
166
222
  leadId: input.leadId,
167
- slotAt: input.slotAt,
168
- ...optionalField("provider", input.provider),
169
- ...optionalField("bookingTypeId", input.bookingTypeId),
223
+ bookingTypeId,
224
+ slotId: exact.id,
225
+ startsAt: exact.startsAt,
226
+ endsAt: exact.endsAt,
227
+ timezone: exact.timezone,
228
+ inviteeEmail: input.inviteeEmail,
229
+ ...optionalField("inviteeName", input.inviteeName),
170
230
  },
171
- });
231
+ }).pipe(
232
+ // The slot went between the read and the booking. Hand back the rest
233
+ // so the agent can pick again in one turn.
234
+ Effect.catchIf(isSlotTaken, () => Effect.succeed(taken)));
172
235
  }),
173
236
  });
174
237
  /** How long a reschedule link stays usable when the caller names no window. */
@@ -176,7 +239,7 @@ const DEFAULT_GUEST_LINK_HOURS = 168;
176
239
  const rescheduleCall = taskTool({
177
240
  name: "luca_reschedule_call",
178
241
  title: "Move a call to a new time",
179
- 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 from luca_book_call. 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.",
180
243
  inputSchema: {
181
244
  ...WORKSPACE_INPUT_FIELDS,
182
245
  bookingId: NonEmptyString.annotate({ description: "The booking to move." }),
@@ -214,7 +277,12 @@ const rescheduleCall = taskTool({
214
277
  // availability. The read happens only when the availability check does.
215
278
  const open = yield* openSlotsAt(input, slotAt, (window) => Effect.flatMap(bookingTypeOf(call, bookingId), (bookingType) => Option.match(Option.flatMapNullishOr(bookingType, (id) => id), {
216
279
  onNone: () => providerSlots(call, input, window),
217
- onSome: (bookingTypeId) => bookingTypeSlots(call, bookingTypeId, window),
280
+ // Compared as instants, so the display timezone does not matter.
281
+ onSome: (bookingTypeId) => managedSlots(call, {
282
+ ...window,
283
+ bookingTypeId,
284
+ timezone: "UTC",
285
+ }).pipe(Effect.map(Option.some)),
218
286
  })));
219
287
  if (Option.isSome(open) &&
220
288
  !open.value.some((candidate) => sameInstant(candidate, slotAt))) {
@@ -9,7 +9,7 @@ const isNonEmptyString = Schema.is(NonEmptyString);
9
9
  const postCallQueue = taskTool({
10
10
  name: "luca_post_call_queue",
11
11
  title: "List the post-call work",
12
- description: "Return everything waiting on the coach after a call: calls that still need a report on how they went, and attended calls with no revenue outcome recorded. The two halves are tracked separately by the API, so a call can sit on one list and not the other. Close an item with luca_close_call_loop. Example: call with {} after a day of calls.",
12
+ description: "Return everything waiting on the coach after a call: calls that still need a report on how they went, and attended calls with no revenue outcome recorded. The two halves are tracked separately by the API, so a call can sit on one list and not the other. An item leaves the list once the coach reports how the call went and what it was worth. Example: call with {} after a day of calls.",
13
13
  inputSchema: {
14
14
  ...WORKSPACE_INPUT_FIELDS,
15
15
  },
@@ -60,7 +60,7 @@ function logExplainFanOut(attempts) {
60
60
  const triageInbox = taskTool({
61
61
  name: TRIAGE_INBOX_TOOL_NAME,
62
62
  title: "Triage the review queue",
63
- description: "Return the coach's prioritized reply queue: pending drafts with lead context and, for the top items, why Luca drafted what it drafted. With a redacted OAuth grant, lead names and draft text can be blanked; describe them as unavailable rather than empty. Start every review session here, then act on an item with luca_approve_and_send, luca_draft_reply, or luca_review_queue_reject. Example: call with { explainTop: 3 } to get the queue plus explanations for the three most urgent drafts.",
63
+ description: "Return the coach's prioritized reply queue: pending drafts with lead context and, for the top items, why Luca drafted what it drafted. With a redacted OAuth grant, lead names and draft text can be blanked; describe them as unavailable rather than empty. Start a review session here: it reads the queue and sends nothing. Example: call with { explainTop: 3 } to get the queue plus explanations for the three most urgent drafts.",
64
64
  inputSchema: {
65
65
  ...WORKSPACE_INPUT_FIELDS,
66
66
  limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 50 })).annotate({
@@ -48,7 +48,7 @@ const flagForHuman = taskTool({
48
48
  const draftReply = taskTool({
49
49
  name: "luca_draft_reply",
50
50
  title: "Draft a reply for a lead",
51
- description: 'Draft a voice-matched reply for a lead on demand and queue it for review — the middle step of the reply loop (triage → draft → approve). Optional guidance steers tone or content. Nothing is sent; approve it with luca_approve_and_send. Example: call with { leadId, guidance: "keep it short and warm" }.',
51
+ description: 'Draft a reply for a lead, written the way the coach writes, and put it in the SetLuca review queue, where it waits for the coach to approve it. It never sends, whatever the channel\'s auto-send setting. Optional guidance steers tone or content. Example: call with { leadId, guidance: "keep it short and warm" }.',
52
52
  inputSchema: {
53
53
  ...WORKSPACE_INPUT_FIELDS,
54
54
  leadId: NonEmptyString.annotate({
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.4.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,14 +1,16 @@
1
1
  {
2
2
  "name": "@setluca/mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "private": false,
5
- "description": "Let your AI assistant help with Luca conversations, leads, replies, campaigns, and bookings.",
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": [
7
7
  "ai",
8
8
  "luca",
9
9
  "mcp",
10
- "model-context-protocol"
10
+ "model-context-protocol",
11
+ "setluca"
11
12
  ],
13
+ "homepage": "https://setluca.com",
12
14
  "license": "Apache-2.0",
13
15
  "repository": {
14
16
  "type": "git",