@setluca/mcp 0.5.0 → 0.5.2

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/http.js CHANGED
@@ -171,13 +171,13 @@ function sendOnce(config, input, requestIdempotencyKey, fetchImpl) {
171
171
  const now = yield* Clock.currentTimeMillis;
172
172
  const waitMs = retryAfterMs(response, now);
173
173
  const text = yield* readText.pipe(Effect.orElseSucceed(() => ""));
174
- return yield* Effect.fail(new LucaHttpError({
174
+ return yield* new LucaHttpError({
175
175
  status: response.status,
176
176
  statusText: response.statusText,
177
177
  body: yield* errorBody(text),
178
178
  ...optionalField("requestId", response.headers.get("x-request-id")),
179
179
  ...optionalField("retryAfterMs", waitMs),
180
- }));
180
+ });
181
181
  }
182
182
  const text = yield* readText;
183
183
  // An empty body is not a decode failure. Several endpoints answer with
@@ -38,7 +38,7 @@ function resolvePayload(response) {
38
38
  return Effect.tryPromise({
39
39
  try: () => response.json(),
40
40
  catch: unavailable("Token verification returned non-JSON content"),
41
- }).pipe(Effect.flatMap((body) => decodeResolvePayload(body).pipe(Effect.mapError(unavailable("Token verification returned an unexpected body")))), Effect.map(Option.some));
41
+ }).pipe(Effect.flatMap((body) => decodeResolvePayload(body).pipe(Effect.mapError(unavailable("Token verification returned an unexpected body")))), Effect.asSome);
42
42
  }
43
43
  /**
44
44
  * One authenticated call to POST /oauth/resolve, answered with the config for
@@ -115,7 +115,7 @@ export const bookingsOperations = [
115
115
  mutatesExisting: true,
116
116
  // Moves the call on the provider calendar and re-arms the lead's reminder.
117
117
  confirm: "always",
118
- description: "Reschedule or update booking status.",
118
+ description: "Reschedule or update booking status. A Google Calendar or Cal.com booking changes on that calendar too, and the calendar can email the lead about it.",
119
119
  // Each booking carries a cancellation reason the invitee may have written.
120
120
  untrustedContent: true,
121
121
  }),
@@ -126,7 +126,7 @@ export const bookingsOperations = [
126
126
  mutatesExisting: true,
127
127
  // Cancels a real call on the lead's and the coach's calendars.
128
128
  confirm: "always",
129
- description: "Cancel a booking.",
129
+ description: "Cancel a booking. A Google Calendar or Cal.com booking is cancelled on that calendar too, and the calendar can email the lead about it.",
130
130
  // Each booking carries a cancellation reason the invitee may have written.
131
131
  untrustedContent: true,
132
132
  }),
@@ -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
  }),
@@ -19,7 +19,7 @@ export const campaignsOperations = [
19
19
  op({
20
20
  id: "campaigns.generate",
21
21
  title: "Generate a campaign draft",
22
- description: "Generate a safe AI campaign plan without creating or publishing it.",
22
+ description: "Build a campaign plan from a brief with fixed rules, not an AI model. Nothing is created or published: create the draft from the plan, review it, and publish it yourself.",
23
23
  }),
24
24
  op({
25
25
  id: "campaigns.draft.update",
@@ -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",
@@ -65,6 +66,8 @@ export const conversationsOperations = [
65
66
  untrustedContent: false,
66
67
  title: "Retry a voice-note transcription",
67
68
  mutatesExisting: true,
69
+ // The retried job sends the voice note to OpenRouter's speech-to-text.
70
+ openWorld: true,
68
71
  description: "Queue another transcription attempt for a voice note in this conversation. Only re-transcribes; never drafts or sends. A refusal says why: a retry is already running, the limit is reached, or the audio is gone.",
69
72
  }),
70
73
  ];
@@ -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,13 +18,18 @@ 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",
27
- description: "Update a lead's display name, stage, score, tags, or custom fields.",
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,
32
+ description: "Update a lead's display name, stage, tags, or custom fields. Tags are replaced as a whole list.",
28
33
  }),
29
34
  op({
30
35
  id: "leads.fieldDefinitions.list",
@@ -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",
@@ -88,10 +99,17 @@ export const leadsOperations = [
88
99
  id: "leads.draft",
89
100
  title: "Draft a reply to a lead",
90
101
  untrustedContent: true,
91
- description: "Draft a reply for a lead, written the way the coach writes, and put it in the review queue for the coach to approve. It never sends, whatever the channel's auto-send setting. Optional guidance steers tone or content.",
102
+ // Drafting again replaces the lead's pending on-demand draft, as
103
+ // reviewQueue.regenerate replaces a queued one.
104
+ mutatesExisting: true,
105
+ // The drafter sends the conversation to OpenRouter's language model, the
106
+ // same reach that makes a transcription retry open-world.
107
+ openWorld: true,
108
+ description: "Draft a reply for a lead, written the way the coach writes, and put it in the review queue for the coach to approve. Drafting again replaces this lead's earlier unapproved draft from this tool. It never sends, whatever the channel's auto-send setting. Optional guidance steers tone or content.",
92
109
  }),
93
110
  op({
94
111
  id: "leads.notes.delete",
112
+ idempotent: true,
95
113
  // Answers with ids and status flags only; no lead-authored text.
96
114
  untrustedContent: false,
97
115
  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",
@@ -42,6 +44,9 @@ export const reviewQueueOperations = [
42
44
  untrustedContent: false,
43
45
  title: "Retry a draft's failed media",
44
46
  mutatesExisting: true,
47
+ // The same transcription job as conversations.media_retry, so the voice
48
+ // note goes to OpenRouter's speech-to-text.
49
+ openWorld: true,
45
50
  description: "Retry a failed voice-note transcription so the draft can be reviewed. Only re-transcribes; never redrafts or sends.",
46
51
  }),
47
52
  op({
@@ -54,7 +59,6 @@ export const reviewQueueOperations = [
54
59
  // Answers with ids and status flags only; no lead-authored text.
55
60
  untrustedContent: false,
56
61
  title: "Record an objection variant choice",
57
- mutatesExisting: true,
58
62
  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
63
  }),
60
64
  op({
@@ -76,6 +80,7 @@ export const reviewQueueOperations = [
76
80
  }),
77
81
  op({
78
82
  id: "reviewQueue.drafting.cancel",
83
+ idempotent: true,
79
84
  // Answers with a turn id and state only; no lead-authored text.
80
85
  untrustedContent: false,
81
86
  title: "Cancel a draft in progress",
@@ -95,6 +100,7 @@ export const reviewQueueOperations = [
95
100
  }),
96
101
  op({
97
102
  id: "reviewQueue.drafting.dismiss",
103
+ idempotent: true,
98
104
  // Answers with no body.
99
105
  untrustedContent: false,
100
106
  title: "Dismiss a stopped draft",
@@ -124,6 +130,9 @@ export const reviewQueueOperations = [
124
130
  }),
125
131
  op({
126
132
  id: "reviewQueue.bulkReject",
133
+ idempotent: true,
134
+ // Sends review.rejected for each draft to the coach's webhook subscribers.
135
+ openWorld: true,
127
136
  // Answers with one outcome per id; no lead-authored text.
128
137
  untrustedContent: false,
129
138
  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,
@@ -19,6 +19,30 @@ const isCursor = Schema.is(NonEmptyString);
19
19
  function pageItems(page, itemsKey) {
20
20
  return Option.getOrElse(decodeItems(page[itemsKey]), () => []);
21
21
  }
22
+ const hasStringId = Schema.is(Schema.Struct({ id: Schema.String }));
23
+ /**
24
+ * A reader for the ids of the first `limit` records on a page of `operation`,
25
+ * for a caller that fans out over what a list route returned. The records sit
26
+ * under the field the route's page contract names: `items` on the review
27
+ * queue, `campaigns` on a campaign page. A page without that array, or a record
28
+ * without a string id, contributes nothing, and the caller still has the page.
29
+ *
30
+ * Throws when `operation` is not a list route. Naming one is a wiring mistake,
31
+ * and an empty id list is exactly how it would stay hidden.
32
+ */
33
+ export function recordIdsOf(operation) {
34
+ const itemsKey = operation.pageContract?.itemsKey;
35
+ if (itemsKey === undefined) {
36
+ // oxlint-disable-next-line effect/avoid-untagged-errors -- raw failure on an invariant or interop path; not a caller-recoverable domain failure
37
+ throw new Error(`${operation.id} carries no list of records to read ids from`);
38
+ }
39
+ return (page, limit) => isJsonObject(page)
40
+ ? pageItems(page, itemsKey)
41
+ .slice(0, limit)
42
+ .filter(hasStringId)
43
+ .map((record) => record.id)
44
+ : [];
45
+ }
22
46
  /**
23
47
  * The page as something the walk can merge, or nothing when the body is not an
24
48
  * object or does not carry the array the route's page shape names. Merging a
@@ -104,7 +128,7 @@ function walkPages(walk, ref) {
104
128
  // list would read as "page 3 is down", and only a failed tool result
105
129
  // carries the challenge that sends the client back through sign-in.
106
130
  if (Result.isFailure(page) && isAuthorizationFailure(page.failure)) {
107
- return yield* Effect.fail(page.failure);
131
+ return yield* page.failure;
108
132
  }
109
133
  yield* Ref.set(ref, Result.match(page, {
110
134
  onFailure: (error) => failedPage(state, formatError(error)),
package/dist/remote.js CHANGED
@@ -11,7 +11,7 @@ import { bearerChallenge } from "./oauth-challenge.js";
11
11
  import { logAgentSurfaceEvent } from "./observability.js";
12
12
  import { optionalField } from "./optional-field.js";
13
13
  import { RATE_LIMIT_WINDOW_MS, createLocalRateLimits, } from "./rate-limit.js";
14
- import { OAUTH_DEFAULT_SCOPE } from "./scopes.js";
14
+ import { OAUTH_CHALLENGE_SCOPE } from "./scopes.js";
15
15
  import { toJsonPayload } from "./serialization.js";
16
16
  import { createLucaServer } from "./server.js";
17
17
  import { sha256Hex } from "./token-verification.js";
@@ -81,13 +81,14 @@ const throttleAnonymous = (request, rateLimits) => consume(rateLimits.perAnonymo
81
81
  /**
82
82
  * A 401 with the RFC 6750 challenge. `resource_metadata` points OAuth clients
83
83
  * at the RFC 9728 document that names the authorization server. Only a request
84
- * with no token also gets the `scope` hint: a client whose token was refused
84
+ * with no token also gets the `scope` hint, which names every scope so the
85
+ * coach can choose on the consent screen: a client whose token was refused
85
86
  * must refresh it, not ask for a different scope.
86
87
  */
87
88
  function unauthorized(detail, resourceMetadataUrl, error) {
88
89
  const challenge = bearerChallenge({
89
90
  resourceMetadataUrl,
90
- ...(error === undefined ? { scope: OAUTH_DEFAULT_SCOPE } : { error }),
91
+ ...(error === undefined ? { scope: OAUTH_CHALLENGE_SCOPE } : { error }),
91
92
  });
92
93
  return jsonResponse(401, { error: "unauthorized", detail }, { "www-authenticate": challenge });
93
94
  }
package/dist/scopes.js CHANGED
@@ -40,6 +40,20 @@ export const LUCA_OAUTH_SCOPES = [
40
40
  export const LUCA_RESOURCE_SCOPES = Arr.filter(LUCA_OAUTH_SCOPES, (scope) => scope !== "offline_access");
41
41
  /** Least-privilege default for a new OAuth connection. */
42
42
  export const OAUTH_DEFAULT_SCOPE = "luca:read luca:draft";
43
+ /**
44
+ * The scope a 401 challenge asks for: every scope this resource accepts, plus
45
+ * `offline_access`. Claude and ChatGPT request exactly what the challenge
46
+ * names, and the consent screen offers only what was requested, so naming less
47
+ * would leave a coach no way to let an assistant send or book. The screen
48
+ * still starts at {@link OAUTH_DEFAULT_SCOPE}: read is locked on, draft starts
49
+ * on, and every higher scope starts unchecked for the coach to tick.
50
+ *
51
+ * `offline_access` is here for the refresh token. The authorization server
52
+ * issues one only when the grant carries it, and a client asks for no more
53
+ * than the challenge names, so without it every connection lapses when its
54
+ * one-hour access token does.
55
+ */
56
+ export const OAUTH_CHALLENGE_SCOPE = LUCA_OAUTH_SCOPES.join(" ");
43
57
  const READ_TIER_SCOPES = [
44
58
  "analytics:read",
45
59
  "bookings:read",
package/dist/server.js CHANGED
@@ -25,12 +25,30 @@ import { fromOperation, fromTaskTool, operationProgram, registerTool, } from "./
25
25
  import { TOOLSETS } from "./toolsets.js";
26
26
  import { LUCA_MCP_VERSION } from "./version.js";
27
27
  const LucaLive = LucaApiLive.pipe(Layer.provide(LucaConfigLive));
28
+ /**
29
+ * SetLuca's icons, for the Worker's server card. They stay out of
30
+ * {@link LUCA_SERVER_INFO}: claude.ai abandons a session whose `initialize`
31
+ * answer carries `serverInfo.icons`, at any protocol version
32
+ * (anthropics/claude-ai-mcp#474, open). The directories take their icon from
33
+ * the listing upload, not the handshake, so leaving them out costs nothing.
34
+ */
35
+ export const LUCA_SERVER_ICONS = [
36
+ {
37
+ src: "https://setluca.com/favicon.svg?v=wordmark-1",
38
+ mimeType: "image/svg+xml",
39
+ },
40
+ {
41
+ src: "https://setluca.com/android-chrome-512x512.png?v=wordmark-1",
42
+ mimeType: "image/png",
43
+ sizes: ["512x512"],
44
+ },
45
+ ];
28
46
  /**
29
47
  * How this server identifies itself to a client. Protocol revision 2026-07-28
30
- * carries a title, description, website, and icons alongside the name and
31
- * version, and a client renders those in its server list instead of the bare
32
- * package name. The Worker's server card is built from the same values, so
33
- * the two descriptions of this server cannot drift apart.
48
+ * carries a title, description, and website alongside the name and version,
49
+ * and a client renders those in its server list instead of the bare package
50
+ * name. The Worker's server card is built from the same values, so the two
51
+ * descriptions of this server cannot drift apart.
34
52
  */
35
53
  export const LUCA_SERVER_INFO = {
36
54
  name: "luca-mcp",
@@ -40,17 +58,6 @@ export const LUCA_SERVER_INFO = {
40
58
  // live and says which one is not.
41
59
  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.",
42
60
  websiteUrl: "https://setluca.com",
43
- icons: [
44
- {
45
- src: "https://setluca.com/favicon.svg?v=wordmark-1",
46
- mimeType: "image/svg+xml",
47
- },
48
- {
49
- src: "https://setluca.com/android-chrome-512x512.png?v=wordmark-1",
50
- mimeType: "image/png",
51
- sizes: ["512x512"],
52
- },
53
- ],
54
61
  };
55
62
  /**
56
63
  * What a client hands its model at connection time: where to start and which
@@ -6,10 +6,13 @@ import * as Result from "effect/Result";
6
6
  import * as Schema from "effect/Schema";
7
7
  import { formatError } from "../errors.js";
8
8
  import { NonEmptyString, WORKSPACE_INPUT_FIELDS } from "../fields.js";
9
+ import { operationById } from "../operations.js";
9
10
  import { optionalField } from "../optional-field.js";
10
- import { failureAsField, listingIds, numberInput, operationResult, resultOrError, sectionResult, taskTool, } from "./runtime.js";
11
+ import { recordIdsOf } from "../pagination.js";
12
+ import { failureAsField, numberInput, operationResult, resultOrError, sectionResult, taskTool, } from "./runtime.js";
11
13
  const DEFAULT_CAMPAIGN_LIMIT = 5;
12
14
  const CAMPAIGN_BROADCAST_LIMIT = 10;
15
+ const campaignIds = recordIdsOf(operationById("campaigns.list"));
13
16
  const ANALYTICS_PERIOD_VALUES = [
14
17
  "this_week",
15
18
  "this_month",
@@ -130,7 +133,7 @@ function analyticsFanOut(call, period, reads) {
130
133
  * {@link failureAsField}.
131
134
  */
132
135
  function campaignAnalytics(call, page, limit) {
133
- return Effect.forEach(listingIds(page, limit), (id) => failureAsField("analyticsError", call("campaigns.analytics", { pathParams: { id } })).pipe(Effect.map((analytics) => [id, analytics])),
136
+ return Effect.forEach(campaignIds(page, limit), (id) => failureAsField("analyticsError", call("campaigns.analytics", { pathParams: { id } })).pipe(Effect.map((analytics) => [id, analytics])),
134
137
  // Effect.forEach also defaults to concurrency 1. The limit caps this at 5
135
138
  // independent per-campaign lookups, so unbounded is fine.
136
139
  { concurrency: "unbounded" }).pipe(Effect.map(R.fromEntries));
@@ -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 SetLuca 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. SetLuca sends the lead no DM about the move, but a Google Calendar booking's calendar emails the lead the updated invite, and Cal.com may too. 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." }),
@@ -282,7 +282,7 @@ const rescheduleCall = taskTool({
282
282
  ...window,
283
283
  bookingTypeId,
284
284
  timezone: "UTC",
285
- }).pipe(Effect.map(Option.some)),
285
+ }).pipe(Effect.asSome),
286
286
  })));
287
287
  if (Option.isSome(open) &&
288
288
  !open.value.some((candidate) => sameInstant(candidate, slotAt))) {