@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 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, parseToolset } from "./server.js";
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
  /**
@@ -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
- * also sends the grant's scopes; the Luca API enforces them on every call, so
11
- * this resolver does not read them. An empty key is an API fault, not a key,
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((payload) => Option.flatMapNullishOr(payload, (body) => body.apiKey).pipe(Option.map((apiKey) => ({
54
- ...settings,
55
- apiKey: Redacted.make(apiKey),
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, parseToolset } from "./server.js";
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. `"tasks"` exposes only the task tools
303
- * plus capability discovery, and `"full"`, the default, exposes everything.
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
- // The tasks set carries one operation, capability discovery, so a client can
321
- // still learn what the workspace supports without the full tool list.
322
- const operations = toolset === "tasks" ? [operationById("capabilities.get")] : LUCA_OPERATIONS;
323
- Arr.forEach(operations, (operation) => {
324
- registerTool(server, fromOperation(operation), lucaLayer, surface);
325
- });
326
- Arr.forEach(LUCA_TASK_TOOLS, (tool) => {
327
- registerTool(server, fromTaskTool(tool), lucaLayer, surface);
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: 'Book a lead into a slot the provider confirms is open. 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.',
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. Moving the call updates the connected calendar and 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.',
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." }),
@@ -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.3.6";
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}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@setluca/mcp",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "Let your AI assistant help with Luca conversations, leads, replies, campaigns, and bookings.",
6
6
  "keywords": [