@setluca/mcp 0.3.7 → 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,17 @@ 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
+
13
24
  ## [0.3.7] - 2026-10-13
14
25
 
15
26
  ### 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
  }
@@ -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.7";
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.7",
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": [