@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.
- package/CHANGELOG.md +111 -0
- package/LICENSE +202 -21
- package/README.md +13 -11
- package/dist/annotations.js +6 -5
- package/dist/generated/openapi-inputs.js +44 -23
- package/dist/generated/openapi-outputs.js +4895 -684
- package/dist/generated/route-catalog.js +168 -0
- package/dist/landing.js +97 -0
- package/dist/operations/groups/bookings.js +31 -0
- package/dist/operations/groups/broadcasts.js +4 -0
- package/dist/operations/groups/cadences.js +1 -0
- package/dist/operations/groups/callEvents.js +15 -1
- package/dist/operations/groups/channels.js +13 -0
- package/dist/operations/groups/conversations.js +34 -0
- package/dist/operations/groups/insights.js +5 -2
- package/dist/operations/groups/integrations.js +17 -4
- package/dist/operations/groups/knowledge.js +2 -0
- package/dist/operations/groups/leads.js +36 -1
- package/dist/operations/groups/learning.js +1 -0
- package/dist/operations/groups/memory.js +1 -0
- package/dist/operations/groups/reviewQueue.js +80 -1
- package/dist/operations/groups/webhooks.js +1 -0
- package/dist/operations/registry.js +11 -4
- package/dist/prompts.js +2 -2
- package/dist/remote.js +6 -0
- package/dist/resources.js +1 -1
- package/dist/scopes.js +13 -11
- package/dist/server.js +5 -3
- package/dist/task-tools/analytics.js +1 -1
- package/dist/task-tools/bookings.js +112 -44
- package/dist/task-tools/calls.js +1 -1
- package/dist/task-tools/inbox.js +1 -1
- package/dist/task-tools/leads.js +1 -1
- package/dist/tool.js +3 -0
- package/dist/version.js +1 -1
- 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 {
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
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
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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: "
|
|
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
|
-
|
|
129
|
-
description: "The
|
|
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
|
-
|
|
132
|
-
description: "
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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:
|
|
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
|
-
|
|
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))) {
|
package/dist/task-tools/calls.js
CHANGED
|
@@ -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.
|
|
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
|
},
|
package/dist/task-tools/inbox.js
CHANGED
|
@@ -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
|
|
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({
|
package/dist/task-tools/leads.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "
|
|
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",
|