@setluca/mcp 0.4.0 → 0.5.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/dist/remote.js CHANGED
@@ -6,6 +6,7 @@ import * as Stream from "effect/Stream";
6
6
  import * as Str from "effect/String";
7
7
  import { addressKey } from "./client-address.js";
8
8
  import { LucaApi, createLucaApi } from "./http.js";
9
+ import { browserLandingPage } from "./landing.js";
9
10
  import { bearerChallenge } from "./oauth-challenge.js";
10
11
  import { logAgentSurfaceEvent } from "./observability.js";
11
12
  import { optionalField } from "./optional-field.js";
@@ -137,6 +138,11 @@ export function createRemoteResponder(options) {
137
138
  const allowedOrigins = options.allowedOrigins ?? [];
138
139
  return (request) => Effect.gen(function* () {
139
140
  const url = new URL(request.url);
141
+ // A person opening the server in a browser gets a page, not the 401.
142
+ const landingPage = browserLandingPage(request, mcpPath);
143
+ if (landingPage !== undefined) {
144
+ return landingPage;
145
+ }
140
146
  if (url.pathname !== mcpPath) {
141
147
  return yield* Effect.fail(notFound());
142
148
  }
package/dist/resources.js CHANGED
@@ -21,7 +21,7 @@ export const LUCA_RESOURCES = [
21
21
  },
22
22
  {
23
23
  name: "luca-queue-today",
24
- title: "Luca review queue (today)",
24
+ title: "SetLuca review queue (today)",
25
25
  description: "The current pending review queue: drafts awaiting the coach's approval. Reuses the pipeline's persisted triage; does not re-run it.",
26
26
  operationId: "reviewQueue.list",
27
27
  uri: "luca://queue/today",
package/dist/scopes.js CHANGED
@@ -90,12 +90,15 @@ const QUEUE_OPS_TIER_SCOPES = [
90
90
  "review_queue:write",
91
91
  ];
92
92
  /**
93
- * Account plumbing with external egress. Webhook subscriptions and CRM
94
- * connections send workspace data to a third-party host, so they sit alone at
95
- * the top rather than riding along with the lead-visible writes.
93
+ * Account plumbing with external egress or standing effect. Webhook
94
+ * subscriptions and CRM connections send workspace data to a third-party host,
95
+ * and a channel's auto-send switch decides whether every later reply reaches a
96
+ * lead without review, so they sit alone at the top rather than riding along
97
+ * with the one-off lead-visible writes.
96
98
  */
97
99
  const FULL_TIER_SCOPES = [
98
100
  ...QUEUE_OPS_TIER_SCOPES,
101
+ "channels:write",
99
102
  "integrations:write",
100
103
  "webhooks:write",
101
104
  ];
@@ -164,9 +167,11 @@ export function highestCapabilityScope(scopes) {
164
167
  export function lowestCapabilityScopeFor(apiScope) {
165
168
  return CAPABILITY_LADDER.find((scope) => tierGrants(scope, apiScope));
166
169
  }
167
- /** The higher of two capability scopes. */
168
- function higherCapabilityScope(left, right) {
169
- return capabilityRank(left) >= capabilityRank(right) ? left : right;
170
+ /** The higher of two capability tiers. */
171
+ export function higherTier(left, right) {
172
+ return CAPABILITY_TIERS.indexOf(left) >= CAPABILITY_TIERS.indexOf(right)
173
+ ? left
174
+ : right;
170
175
  }
171
176
  /**
172
177
  * The least-privilege capability tier that lets an OAuth connection call an
@@ -184,10 +189,7 @@ function higherCapabilityScope(left, right) {
184
189
  */
185
190
  export function effectiveCapability(operation) {
186
191
  const routeTier = CAPABILITY_TIERS.find((tier) => operation.scopes.some((apiScope) => tierGrants(scopeForTier(tier), apiScope))) ?? "full";
187
- return CAPABILITY_TIERS.indexOf(routeTier) >=
188
- CAPABILITY_TIERS.indexOf(operation.requiredScope.capability)
189
- ? routeTier
190
- : operation.requiredScope.capability;
192
+ return higherTier(routeTier, operation.requiredScope.capability);
191
193
  }
192
194
  /** The OAuth scope that requests {@link effectiveCapability}. */
193
195
  export function oauthScopeForOperation(operation) {
@@ -198,7 +200,7 @@ export function oauthScopeForOperation(operation) {
198
200
  * operation it composes needs, because the tool runs all of them.
199
201
  */
200
202
  export function oauthScopeForTool(operations) {
201
- return Arr.reduce(operations, scopeForTier("read"), (scope, operation) => higherCapabilityScope(scope, oauthScopeForOperation(operation)));
203
+ return highestCapabilityScope(Arr.map(operations, oauthScopeForOperation));
202
204
  }
203
205
  /**
204
206
  * API scopes the operations need that no capability tier grants, sorted and
package/dist/server.js CHANGED
@@ -34,9 +34,11 @@ const LucaLive = LucaApiLive.pipe(Layer.provide(LucaConfigLive));
34
34
  */
35
35
  export const LUCA_SERVER_INFO = {
36
36
  name: "luca-mcp",
37
- title: "Luca",
37
+ title: "SetLuca",
38
38
  version: LUCA_MCP_VERSION,
39
- description: "Luca is the DM sales platform for coaches, creators, and experts who sell through DMs. Manage leads, conversations, drafts, campaigns, broadcasts, and bookings across Instagram, Messenger, WhatsApp, and Telegram.",
39
+ // Claude shows this at connect time, so it names only the channels that are
40
+ // live and says which one is not.
41
+ description: "SetLuca runs DM sales for coaches, creators, and experts. Work with leads, conversations, drafted replies, campaigns, broadcasts, and booked calls on Telegram, Instagram, and Messenger. WhatsApp is coming soon.",
40
42
  websiteUrl: "https://setluca.com",
41
43
  icons: [
42
44
  {
@@ -55,7 +57,7 @@ export const LUCA_SERVER_INFO = {
55
57
  * calls wait for the coach. The tool descriptions carry the detail.
56
58
  */
57
59
  export const LUCA_SERVER_INSTRUCTIONS = [
58
- "Luca runs a coach's DM sales: leads, conversations, drafted replies, campaigns, broadcasts, and booked calls.",
60
+ "SetLuca runs a coach's DM sales: leads, conversations, drafted replies, campaigns, broadcasts, and booked calls.",
59
61
  "Start with the luca_* task tools, such as luca_morning_report and luca_triage_inbox. Read luca://operations for the full list of operations, scopes, and paths.",
60
62
  "A tool whose description asks for confirm: true reaches a lead or an outside system, such as sending a message, launching a campaign, or booking a call, and rejects the call without it. Pass confirm: true only after the coach approves that exact action.",
61
63
  "A redacted OAuth grant may blank lead names, messages, and drafts. Treat blank or null sensitive fields as unavailable, not proof that the underlying record is empty. Say when the connected account cannot see that detail.",
@@ -195,7 +195,7 @@ const magicMonday = taskTool({
195
195
  const analyticsDeepDive = taskTool({
196
196
  name: "luca_analytics_deep_dive",
197
197
  title: "Run a deep-dive analytics investigation",
198
- description: 'Answer a specific business question by composing all seven analytics reads in one call: pipeline funnel, revenue and ROI, response-speed impact, a point-in-time revenue forecast, call intelligence (show/close rates), ghosted-lead recovery, and coach trust/auto-send status. Broader than luca_analytics_magic_monday (which is the fixed weekly report); use this when the coach asks a specific question that needs cross-referencing several of these numbers together. Example: call with {question: "why did close rate drop this month?", period: "this_month"}.',
198
+ description: 'Answer a specific business question by composing all seven analytics reads in one call: pipeline funnel, revenue and ROI, response-speed impact, a point-in-time revenue forecast, call intelligence (show/close rates), ghosted-lead recovery, and coach trust/auto-send status. Use it when the coach asks a specific question that needs several of these numbers side by side, rather than the fixed weekly report. Example: call with {question: "why did close rate drop this month?", period: "this_month"}.',
199
199
  inputSchema: {
200
200
  ...WORKSPACE_INPUT_FIELDS,
201
201
  question: Schema.optionalKey(NonEmptyString.check(Schema.isMaxLength(500)).annotate({
@@ -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. 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/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.0";
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.0",
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",