@setluca/mcp 0.5.1 → 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/CHANGELOG.md +51 -0
- package/dist/completions.js +5 -32
- package/dist/errors.js +2 -2
- package/dist/generated/openapi-inputs.js +42 -42
- package/dist/http.js +2 -2
- package/dist/oauth-resolver.js +1 -1
- package/dist/operations/groups/bookings.js +2 -2
- package/dist/operations/groups/campaigns.js +1 -1
- package/dist/operations/groups/conversations.js +2 -0
- package/dist/operations/groups/leads.js +8 -2
- package/dist/operations/groups/reviewQueue.js +3 -0
- package/dist/pagination.js +25 -1
- package/dist/remote.js +4 -3
- package/dist/scopes.js +14 -0
- package/dist/server.js +22 -15
- package/dist/task-tools/analytics.js +5 -2
- package/dist/task-tools/bookings.js +2 -2
- package/dist/task-tools/inbox.js +5 -2
- package/dist/task-tools/leads.js +1 -1
- package/dist/task-tools/runtime.js +0 -32
- package/dist/toolsets.js +3 -3
- package/dist/version.js +1 -1
- package/package.json +7 -5
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*
|
|
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
|
package/dist/oauth-resolver.js
CHANGED
|
@@ -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.
|
|
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
|
}),
|
|
@@ -19,7 +19,7 @@ export const campaignsOperations = [
|
|
|
19
19
|
op({
|
|
20
20
|
id: "campaigns.generate",
|
|
21
21
|
title: "Generate a campaign draft",
|
|
22
|
-
description: "
|
|
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",
|
|
@@ -66,6 +66,8 @@ export const conversationsOperations = [
|
|
|
66
66
|
untrustedContent: false,
|
|
67
67
|
title: "Retry a voice-note transcription",
|
|
68
68
|
mutatesExisting: true,
|
|
69
|
+
// The retried job sends the voice note to OpenRouter's speech-to-text.
|
|
70
|
+
openWorld: true,
|
|
69
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.",
|
|
70
72
|
}),
|
|
71
73
|
];
|
|
@@ -29,7 +29,7 @@ export const leadsOperations = [
|
|
|
29
29
|
// Sends lead.updated to the coach's webhook subscribers, and a tag change
|
|
30
30
|
// can resume a campaign step that messages the lead.
|
|
31
31
|
openWorld: true,
|
|
32
|
-
description: "Update a lead's display name, stage,
|
|
32
|
+
description: "Update a lead's display name, stage, tags, or custom fields. Tags are replaced as a whole list.",
|
|
33
33
|
}),
|
|
34
34
|
op({
|
|
35
35
|
id: "leads.fieldDefinitions.list",
|
|
@@ -99,7 +99,13 @@ export const leadsOperations = [
|
|
|
99
99
|
id: "leads.draft",
|
|
100
100
|
title: "Draft a reply to a lead",
|
|
101
101
|
untrustedContent: true,
|
|
102
|
-
|
|
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.",
|
|
103
109
|
}),
|
|
104
110
|
op({
|
|
105
111
|
id: "leads.notes.delete",
|
|
@@ -44,6 +44,9 @@ export const reviewQueueOperations = [
|
|
|
44
44
|
untrustedContent: false,
|
|
45
45
|
title: "Retry a draft's failed media",
|
|
46
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,
|
|
47
50
|
description: "Retry a failed voice-note transcription so the draft can be reviewed. Only re-transcribes; never redrafts or sends.",
|
|
48
51
|
}),
|
|
49
52
|
op({
|
package/dist/pagination.js
CHANGED
|
@@ -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*
|
|
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 {
|
|
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
|
|
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:
|
|
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,
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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 {
|
|
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(
|
|
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
|
|
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.
|
|
285
|
+
}).pipe(Effect.asSome),
|
|
286
286
|
})));
|
|
287
287
|
if (Option.isSome(open) &&
|
|
288
288
|
!open.value.some((candidate) => sameInstant(candidate, slotAt))) {
|
package/dist/task-tools/inbox.js
CHANGED
|
@@ -5,9 +5,12 @@ import * as Schema from "effect/Schema";
|
|
|
5
5
|
import { formatError } from "../errors.js";
|
|
6
6
|
import { WORKSPACE_INPUT_FIELDS } from "../fields.js";
|
|
7
7
|
import { logAgentSurfaceEvent } from "../observability.js";
|
|
8
|
-
import {
|
|
8
|
+
import { operationById } from "../operations.js";
|
|
9
|
+
import { recordIdsOf } from "../pagination.js";
|
|
10
|
+
import { isPartialFailure, numberInput, operationResult, resultOrError, taskTool, } from "./runtime.js";
|
|
9
11
|
const DEFAULT_QUEUE_LIMIT = 20;
|
|
10
12
|
const DEFAULT_EXPLAIN_TOP = 3;
|
|
13
|
+
const queueIds = recordIdsOf(operationById("reviewQueue.list"));
|
|
11
14
|
const TRIAGE_INBOX_TOOL_NAME = "luca_triage_inbox";
|
|
12
15
|
/**
|
|
13
16
|
* One explanation, with a failed lookup carried in the result instead of
|
|
@@ -79,7 +82,7 @@ const triageInbox = taskTool({
|
|
|
79
82
|
const queue = yield* call("reviewQueue.list", {
|
|
80
83
|
query: { limit: numberInput(input.limit, DEFAULT_QUEUE_LIMIT) },
|
|
81
84
|
});
|
|
82
|
-
const ids =
|
|
85
|
+
const ids = queueIds(queue, numberInput(input.explainTop, DEFAULT_EXPLAIN_TOP));
|
|
83
86
|
const attempts = yield* Effect.forEach(ids, (id) => explainOne(call, id),
|
|
84
87
|
// Effect.forEach defaults to concurrency 1 (sequential). `explainTop`
|
|
85
88
|
// caps this fan-out at 5 independent lookups, so unbounded is fine.
|
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:
|
|
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. 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. Example: call with { leadId, guidance: \"keep it short and warm\" }.",
|
|
52
52
|
inputSchema: {
|
|
53
53
|
...WORKSPACE_INPUT_FIELDS,
|
|
54
54
|
leadId: NonEmptyString.annotate({
|
|
@@ -63,38 +63,6 @@ export function taskRequest(operation, input, parts = {}) {
|
|
|
63
63
|
...optionalField("workspace", workspaceOverride(input)),
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
|
-
/**
|
|
67
|
-
* The only part of a listing page a composing tool reads for itself: the ids of
|
|
68
|
-
* the items it then fans out over. Decoding that alone leaves the raw page
|
|
69
|
-
* intact for the caller, returned exactly as the API sent it, while the ids the
|
|
70
|
-
* tool acts on are validated rather than asserted.
|
|
71
|
-
*/
|
|
72
|
-
const ListingIds = Schema.Struct({
|
|
73
|
-
items: Schema.optional(Schema.Array(Schema.Struct({ id: Schema.optional(Schema.String) }))),
|
|
74
|
-
});
|
|
75
|
-
const decodeListingIds = Schema.decodeUnknownOption(ListingIds);
|
|
76
|
-
/**
|
|
77
|
-
* Ids of the first `limit` items on a listing page. A page whose fields we
|
|
78
|
-
* cannot read, or whose items carry no id, contributes nothing. The tool still
|
|
79
|
-
* returns the page itself rather than failing over a fan-out it cannot do.
|
|
80
|
-
*/
|
|
81
|
-
export function listingIds(page, limit) {
|
|
82
|
-
// Both fallbacks below are empty arrays, and substituting a one-item array
|
|
83
|
-
// for either is an equivalent mutation rather than an untested branch: a
|
|
84
|
-
// substituted item carries no `id`, so the `filter` at the end of this
|
|
85
|
-
// function drops it and the result is the same empty id list. Substituting
|
|
86
|
-
// `undefined` for the outer fallback is a real change and is covered by the
|
|
87
|
-
// undecodable-page test.
|
|
88
|
-
const items = decodeListingIds(page).pipe(
|
|
89
|
-
// Stryker disable next-line ArrayDeclaration: a substituted item has no id and is filtered out below
|
|
90
|
-
Option.map((decoded) => decoded.items ?? []),
|
|
91
|
-
// Stryker disable next-line ArrayDeclaration: a substituted item has no id and is filtered out below
|
|
92
|
-
Option.getOrElse(() => []));
|
|
93
|
-
return items
|
|
94
|
-
.slice(0, limit)
|
|
95
|
-
.map((item) => item.id)
|
|
96
|
-
.filter((id) => id !== undefined);
|
|
97
|
-
}
|
|
98
66
|
/**
|
|
99
67
|
* A request body carrying a single text field, or no body at all when the input
|
|
100
68
|
* is absent or blank. Sending an empty string would mean something different to
|
package/dist/toolsets.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { LUCA_TASK_TOOLS } from "./task-tools.js";
|
|
2
2
|
/**
|
|
3
|
-
* The tools the `claude` toolset exposes
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* The tools the `claude` toolset exposes, and a test pins it name for name.
|
|
4
|
+
* Coaches who chose `?toolset=claude` rely on it staying read and draft, so a
|
|
5
|
+
* tool that sends, books, or changes settings does not belong here.
|
|
6
6
|
*/
|
|
7
7
|
export const CLAUDE_TOOL_NAMES = [
|
|
8
8
|
"luca_triage_inbox",
|
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.5.
|
|
12
|
+
export const LUCA_MCP_VERSION = "0.5.2";
|
|
13
13
|
/** The user-agent every outbound Luca API call identifies itself with. */
|
|
14
14
|
export const LUCA_MCP_USER_AGENT = `luca-mcp/${LUCA_MCP_VERSION}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@setluca/mcp",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"private": false,
|
|
5
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": [
|
|
@@ -68,15 +68,17 @@
|
|
|
68
68
|
"registry:check": "bun scripts/check-registry.ts",
|
|
69
69
|
"docs:generate": "bun scripts/generate-docs.ts --write",
|
|
70
70
|
"docs:check": "bun scripts/generate-docs.ts --check",
|
|
71
|
-
"clean": "rm -rf .turbo dist coverage node_modules"
|
|
71
|
+
"clean": "rm -rf .turbo dist coverage node_modules",
|
|
72
|
+
"prepare": "effect-tsgo patch --typescript --no-oxlint"
|
|
72
73
|
},
|
|
73
74
|
"dependencies": {
|
|
74
75
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
75
|
-
"effect": "4.0.
|
|
76
|
+
"effect": "4.0.6"
|
|
76
77
|
},
|
|
77
78
|
"devDependencies": {
|
|
78
|
-
"@effect/platform-bun": "4.0.
|
|
79
|
-
"@effect/
|
|
79
|
+
"@effect/platform-bun": "4.0.6",
|
|
80
|
+
"@effect/tsgo": "0.56.0",
|
|
81
|
+
"@effect/vitest": "4.0.6",
|
|
80
82
|
"@modelcontextprotocol/client": "^2.0.0",
|
|
81
83
|
"@modelcontextprotocol/inspector": "^2.5.0",
|
|
82
84
|
"@mpsuesser/oxlint-plugin-effect": "0.6.0",
|