@setluca/mcp 0.3.6 → 0.4.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/CHANGELOG.md +23 -0
- package/dist/index.js +2 -1
- package/dist/oauth-resolver.js +12 -6
- package/dist/remote.js +3 -1
- package/dist/scopes.js +9 -0
- package/dist/server.js +17 -21
- package/dist/task-tools/bookings.js +2 -2
- package/dist/toolsets.js +81 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,29 @@ input schema, or changing what a tool does to a workspace.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.4.0] - 2026-10-13
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Add a `claude` toolset, selected with `?toolset=claude` or
|
|
18
|
+
`LUCA_TOOLSET=claude`. It lists a fixed set of 43 tools for the Claude
|
|
19
|
+
connector directory and lists only the `luca-api-planner` and
|
|
20
|
+
`luca-morning-report` prompts. For an OAuth connection
|
|
21
|
+
it also hides tools above the grant's capability tier, so a `luca:read` grant
|
|
22
|
+
sees 39. The `full` and `tasks` toolsets are unchanged.
|
|
23
|
+
|
|
24
|
+
## [0.3.7] - 2026-10-13
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Describe what `luca_book_call` does: it records a booked call in Luca, but it
|
|
29
|
+
doesn't create a calendar event or send the lead an invite. Its description
|
|
30
|
+
said it booked a slot the provider confirmed.
|
|
31
|
+
- Describe which bookings `luca_reschedule_call` can move. Manual bookings move
|
|
32
|
+
in Luca. Cal.com and Google Calendar bookings also move on that calendar.
|
|
33
|
+
Calendly, GoHighLevel and iClosed bookings are refused, and so is a provider
|
|
34
|
+
booking with no calendar event.
|
|
35
|
+
|
|
13
36
|
## [0.3.6] - 2026-10-13
|
|
14
37
|
|
|
15
38
|
### Fixed
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
3
3
|
import { setEventSink, stderrEventSink } from "./observability.js";
|
|
4
|
-
import { createLucaServer
|
|
4
|
+
import { createLucaServer } from "./server.js";
|
|
5
|
+
import { parseToolset } from "./toolsets.js";
|
|
5
6
|
// stdout carries the protocol, so event lines go to stderr.
|
|
6
7
|
setEventSink(stderrEventSink);
|
|
7
8
|
/**
|
package/dist/oauth-resolver.js
CHANGED
|
@@ -4,15 +4,18 @@ import * as Redacted from "effect/Redacted";
|
|
|
4
4
|
import * as Schema from "effect/Schema";
|
|
5
5
|
import { TokenVerificationUnavailable } from "./errors.js";
|
|
6
6
|
import { NonEmptyString } from "./fields.js";
|
|
7
|
+
import { highestCapabilityScope } from "./scopes.js";
|
|
7
8
|
import { callApi, unavailable, } from "./token-verification.js";
|
|
8
9
|
/**
|
|
9
10
|
* The body POST /oauth/resolve answers with for a token it accepted. The API
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* enforces the grant's scopes on every call. This resolver reads them only to
|
|
12
|
+
* learn the capability tier, which the `claude` toolset uses to hide tools the
|
|
13
|
+
* grant cannot call. An empty key is an API fault, not a key,
|
|
12
14
|
* so it fails as unavailable instead of reaching the API as a blank header.
|
|
13
15
|
*/
|
|
14
16
|
const ResolvePayload = Schema.Struct({
|
|
15
17
|
apiKey: Schema.optionalKey(NonEmptyString),
|
|
18
|
+
scopes: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
16
19
|
});
|
|
17
20
|
const decodeResolvePayload = Schema.decodeUnknownEffect(ResolvePayload);
|
|
18
21
|
/**
|
|
@@ -50,10 +53,13 @@ function requestConfig(settings, fetchImpl, token) {
|
|
|
50
53
|
return callApi(settings, fetchImpl, {
|
|
51
54
|
path: "/oauth/resolve",
|
|
52
55
|
init: { method: "POST", headers: { authorization: `Bearer ${token}` } },
|
|
53
|
-
}, resolvePayload).pipe(Effect.map(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
}, resolvePayload).pipe(Effect.map(Option.flatMapNullishOr((body) => body.apiKey === undefined
|
|
57
|
+
? undefined
|
|
58
|
+
: {
|
|
59
|
+
...settings,
|
|
60
|
+
apiKey: Redacted.make(body.apiKey),
|
|
61
|
+
grant: highestCapabilityScope(body.scopes ?? []),
|
|
62
|
+
})));
|
|
57
63
|
}
|
|
58
64
|
/** Three base64url segments: the form a JWT access token takes. */
|
|
59
65
|
const JWT_ACCESS_TOKEN = /^[\w-]+\.[\w-]+\.[\w-]+$/;
|
package/dist/remote.js
CHANGED
|
@@ -12,8 +12,9 @@ import { optionalField } from "./optional-field.js";
|
|
|
12
12
|
import { RATE_LIMIT_WINDOW_MS, createLocalRateLimits, } from "./rate-limit.js";
|
|
13
13
|
import { OAUTH_DEFAULT_SCOPE } from "./scopes.js";
|
|
14
14
|
import { toJsonPayload } from "./serialization.js";
|
|
15
|
-
import { createLucaServer
|
|
15
|
+
import { createLucaServer } from "./server.js";
|
|
16
16
|
import { sha256Hex } from "./token-verification.js";
|
|
17
|
+
import { parseToolset } from "./toolsets.js";
|
|
17
18
|
export { bindingRateLimits, createLocalRateLimits } from "./rate-limit.js";
|
|
18
19
|
export { apiKeyResolver, looksLikeDeveloperKey } from "./resolvers.js";
|
|
19
20
|
/**
|
|
@@ -170,6 +171,7 @@ export function createRemoteResponder(options) {
|
|
|
170
171
|
const handler = createMcpHandler(() => createLucaServer({
|
|
171
172
|
lucaLayer: Layer.succeed(LucaApi, createLucaApi(config.value, options.apiFetch)),
|
|
172
173
|
...optionalField("toolset", options.toolset),
|
|
174
|
+
...optionalField("grant", config.value.grant),
|
|
173
175
|
...optionalField("resourceMetadataUrl", options.resourceMetadataUrl),
|
|
174
176
|
}), { ...optionalField("onerror", options.onError) });
|
|
175
177
|
// The handler lives until the response body is done with, then
|
package/dist/scopes.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as Arr from "effect/Array";
|
|
2
|
+
import * as Option from "effect/Option";
|
|
2
3
|
import * as Order from "effect/Order";
|
|
3
4
|
/**
|
|
4
5
|
* The OAuth scopes this server advertises, and the API-key scopes each one
|
|
@@ -147,6 +148,14 @@ export function tierGrants(scope, apiScope) {
|
|
|
147
148
|
export function capabilityRank(scope) {
|
|
148
149
|
return CAPABILITY_LADDER.indexOf(scope);
|
|
149
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* The highest capability scope in a grant's scope list. A list naming none
|
|
153
|
+
* (only `offline_access`, say, or an empty list) resolves to `luca:read`, the
|
|
154
|
+
* lowest, so an unreadable grant never lists more than a read grant would.
|
|
155
|
+
*/
|
|
156
|
+
export function highestCapabilityScope(scopes) {
|
|
157
|
+
return Arr.findLast(CAPABILITY_LADDER, (scope) => Arr.contains(scopes, scope)).pipe(Option.getOrElse(() => "luca:read"));
|
|
158
|
+
}
|
|
150
159
|
/**
|
|
151
160
|
* The lowest capability scope that grants `apiScope`, or `undefined` when no
|
|
152
161
|
* tier grants it. Reading the ladder from the bottom is what makes the answer
|
package/dist/server.js
CHANGED
|
@@ -16,12 +16,13 @@ import { optionalField } from "./optional-field.js";
|
|
|
16
16
|
import { LUCA_API_PLANNER_PROMPT, LUCA_PROMPTS, } from "./prompts.js";
|
|
17
17
|
import { provenanceFields } from "./provenance.js";
|
|
18
18
|
import { LUCA_OPERATION_MANIFEST_RESOURCE, LUCA_RESOURCES, } from "./resources.js";
|
|
19
|
-
import { oauthScopeForOperation } from "./scopes.js";
|
|
19
|
+
import { capabilityRank, oauthScopeForOperation, } from "./scopes.js";
|
|
20
20
|
import { toJsonText } from "./serialization.js";
|
|
21
21
|
import { toolSchema } from "./standard-schema.js";
|
|
22
22
|
import { LUCA_TASK_TOOLS } from "./task-tools.js";
|
|
23
23
|
import { buildCache } from "./tool-cache.js";
|
|
24
24
|
import { fromOperation, fromTaskTool, operationProgram, registerTool, } from "./tool.js";
|
|
25
|
+
import { TOOLSETS } from "./toolsets.js";
|
|
25
26
|
import { LUCA_MCP_VERSION } from "./version.js";
|
|
26
27
|
const LucaLive = LucaApiLive.pipe(Layer.provide(LucaConfigLive));
|
|
27
28
|
/**
|
|
@@ -94,11 +95,6 @@ const LUCA_CACHE_HINTS = {
|
|
|
94
95
|
},
|
|
95
96
|
"server/discover": { ttlMs: STATIC_LIST_TTL_MS, cacheScope: "private" },
|
|
96
97
|
};
|
|
97
|
-
// Only "tasks" selects the trimmed surface; every other value, including an
|
|
98
|
-
// unset query param, takes the full-surface branch.
|
|
99
|
-
export function parseToolset(value) {
|
|
100
|
-
return value === "tasks" ? "tasks" : "full";
|
|
101
|
-
}
|
|
102
98
|
function registerLucaResources(server, lucaLayer, completions, resourceMetadataUrl) {
|
|
103
99
|
Arr.forEach(LUCA_RESOURCES, (resource) => {
|
|
104
100
|
const operation = operationById(resource.operationId);
|
|
@@ -299,16 +295,14 @@ function registerPrompt(server, prompt, completions) {
|
|
|
299
295
|
* Build the Luca MCP server: registers the operation tools, the task tools, the
|
|
300
296
|
* `luca://…` resources, and the planner prompt against a shared `LucaApi` layer.
|
|
301
297
|
* `options.lucaLayer` swaps the HTTP client (tests inject an in-memory handler);
|
|
302
|
-
* `options.toolset` picks the tool set
|
|
303
|
-
*
|
|
298
|
+
* `options.toolset` picks the tool set from `TOOLSETS`; `"full"`, the default,
|
|
299
|
+
* exposes everything. `options.grant` trims the `claude` set to the tools the
|
|
300
|
+
* connection's OAuth grant can call.
|
|
304
301
|
*/
|
|
305
302
|
export function createLucaServer(options = {}) {
|
|
306
303
|
const lucaLayer = options.lucaLayer ?? LucaLive;
|
|
307
|
-
// The default value is only ever compared against `"tasks"` below; any other
|
|
308
|
-
// string (including `""`) takes the full-surface branch, so the literal here
|
|
309
|
-
// cannot change behavior.
|
|
310
|
-
// Stryker disable next-line StringLiteral: only `"tasks"` is compared downstream, so any non-tasks default behaves as full
|
|
311
304
|
const toolset = options.toolset ?? "full";
|
|
305
|
+
const definition = TOOLSETS[toolset];
|
|
312
306
|
const surface = {
|
|
313
307
|
toolset,
|
|
314
308
|
...optionalField("resourceMetadataUrl", options.resourceMetadataUrl),
|
|
@@ -317,18 +311,20 @@ export function createLucaServer(options = {}) {
|
|
|
317
311
|
cacheHints: LUCA_CACHE_HINTS,
|
|
318
312
|
instructions: LUCA_SERVER_INSTRUCTIONS,
|
|
319
313
|
});
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
Arr.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
314
|
+
const listed = (tool) => !definition.grantFiltered ||
|
|
315
|
+
options.grant === undefined ||
|
|
316
|
+
capabilityRank(tool.oauthScope) <= capabilityRank(options.grant);
|
|
317
|
+
const inToolset = (name) => definition.tools === undefined || Arr.contains(definition.tools, name);
|
|
318
|
+
const tools = [
|
|
319
|
+
...LUCA_OPERATIONS.filter((operation) => inToolset(operation.toolName)).map(fromOperation),
|
|
320
|
+
...LUCA_TASK_TOOLS.filter((tool) => inToolset(tool.name)).map(fromTaskTool),
|
|
321
|
+
].filter(listed);
|
|
322
|
+
Arr.forEach(tools, (tool) => {
|
|
323
|
+
registerTool(server, tool, lucaLayer, surface);
|
|
328
324
|
});
|
|
329
325
|
registerOperationManifestResource(server);
|
|
330
326
|
const completions = completionCallbacks(operationById, lucaLayer);
|
|
331
327
|
registerLucaResources(server, lucaLayer, completions, options.resourceMetadataUrl);
|
|
332
|
-
registerPrompts(server, LUCA_PROMPTS, completions);
|
|
328
|
+
registerPrompts(server, LUCA_PROMPTS.filter((prompt) => !Arr.contains(definition.hiddenPrompts, prompt.name)), completions);
|
|
333
329
|
return server;
|
|
334
330
|
}
|
|
@@ -118,7 +118,7 @@ function slotNotAvailable(key) {
|
|
|
118
118
|
const bookCall = taskTool({
|
|
119
119
|
name: "luca_book_call",
|
|
120
120
|
title: "Book a call with a lead",
|
|
121
|
-
description:
|
|
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.",
|
|
122
122
|
inputSchema: {
|
|
123
123
|
...WORKSPACE_INPUT_FIELDS,
|
|
124
124
|
leadId: NonEmptyString.annotate({ description: "The lead to book." }),
|
|
@@ -176,7 +176,7 @@ const DEFAULT_GUEST_LINK_HOURS = 168;
|
|
|
176
176
|
const rescheduleCall = taskTool({
|
|
177
177
|
name: "luca_reschedule_call",
|
|
178
178
|
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.
|
|
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.',
|
|
180
180
|
inputSchema: {
|
|
181
181
|
...WORKSPACE_INPUT_FIELDS,
|
|
182
182
|
bookingId: NonEmptyString.annotate({ description: "The booking to move." }),
|
package/dist/toolsets.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { LUCA_TASK_TOOLS } from "./task-tools.js";
|
|
2
|
+
/**
|
|
3
|
+
* The tools the `claude` toolset exposes. This list is the contract with the
|
|
4
|
+
* connector directory submission, and a test pins it name for name. Adding or
|
|
5
|
+
* removing a tool here changes what the directory lists.
|
|
6
|
+
*/
|
|
7
|
+
export const CLAUDE_TOOL_NAMES = [
|
|
8
|
+
"luca_triage_inbox",
|
|
9
|
+
"luca_morning_report",
|
|
10
|
+
"luca_find_leads",
|
|
11
|
+
"luca_post_call_queue",
|
|
12
|
+
"luca_analytics_rollup",
|
|
13
|
+
"luca_analytics_magic_monday",
|
|
14
|
+
"luca_analytics_deep_dive",
|
|
15
|
+
"luca_capabilities_get",
|
|
16
|
+
"luca_leads_list",
|
|
17
|
+
"luca_leads_get",
|
|
18
|
+
"luca_leads_explain",
|
|
19
|
+
"luca_conversations_list",
|
|
20
|
+
"luca_conversations_get",
|
|
21
|
+
"luca_conversations_messages",
|
|
22
|
+
"luca_review_queue_list",
|
|
23
|
+
"luca_review_queue_explain",
|
|
24
|
+
"luca_review_queue_sla",
|
|
25
|
+
"luca_bookings_list",
|
|
26
|
+
"luca_bookings_get",
|
|
27
|
+
"luca_bookings_availability",
|
|
28
|
+
"luca_bookings_brief",
|
|
29
|
+
"luca_bookings_needs_outcome",
|
|
30
|
+
"luca_call_events_needing_feedback",
|
|
31
|
+
"luca_call_events_get",
|
|
32
|
+
"luca_channels_list",
|
|
33
|
+
"luca_channels_catalog",
|
|
34
|
+
"luca_reports_scorecard",
|
|
35
|
+
"luca_reports_morning",
|
|
36
|
+
"luca_campaigns_list",
|
|
37
|
+
"luca_campaigns_get",
|
|
38
|
+
"luca_campaigns_analytics",
|
|
39
|
+
"luca_broadcasts_list",
|
|
40
|
+
"luca_broadcasts_get",
|
|
41
|
+
"luca_voice_profile",
|
|
42
|
+
"luca_analytics_funnel",
|
|
43
|
+
"luca_analytics_ghosted_leads",
|
|
44
|
+
"luca_analytics_objections",
|
|
45
|
+
"luca_analytics_insights",
|
|
46
|
+
"luca_memory_list",
|
|
47
|
+
"luca_draft_reply",
|
|
48
|
+
"luca_flag_for_human",
|
|
49
|
+
"luca_leads_notes_add",
|
|
50
|
+
"luca_leads_update",
|
|
51
|
+
];
|
|
52
|
+
export const TOOLSETS = {
|
|
53
|
+
full: { hiddenPrompts: [], grantFiltered: false },
|
|
54
|
+
tasks: {
|
|
55
|
+
tools: [
|
|
56
|
+
"luca_capabilities_get",
|
|
57
|
+
...LUCA_TASK_TOOLS.map((tool) => tool.name),
|
|
58
|
+
],
|
|
59
|
+
hiddenPrompts: [],
|
|
60
|
+
grantFiltered: false,
|
|
61
|
+
},
|
|
62
|
+
claude: {
|
|
63
|
+
tools: CLAUDE_TOOL_NAMES,
|
|
64
|
+
hiddenPrompts: [
|
|
65
|
+
"luca-rescue-silent-leads",
|
|
66
|
+
"luca-plan-campaign",
|
|
67
|
+
"luca-plan-broadcast",
|
|
68
|
+
"luca-close-the-call-loop",
|
|
69
|
+
// These name approve-and-send, reject, or learning tools the set leaves out.
|
|
70
|
+
"luca-triage-queue",
|
|
71
|
+
"luca-draft-reply",
|
|
72
|
+
"luca-weekly-review",
|
|
73
|
+
],
|
|
74
|
+
grantFiltered: true,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
// Only "tasks" and "claude" select a trimmed surface; every other value,
|
|
78
|
+
// including an unset query param, takes the full-surface branch.
|
|
79
|
+
export function parseToolset(value) {
|
|
80
|
+
return value === "tasks" || value === "claude" ? value : "full";
|
|
81
|
+
}
|
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.4.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}`;
|