@setluca/mcp 0.2.1 → 0.3.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +316 -0
  2. package/LICENSE +21 -0
  3. package/README.md +19 -288
  4. package/dist/annotations.js +33 -18
  5. package/dist/client-address.js +64 -0
  6. package/dist/completions.js +24 -27
  7. package/dist/config.js +89 -38
  8. package/dist/errors.js +94 -13
  9. package/dist/fields.js +68 -0
  10. package/dist/fixed-window.js +46 -0
  11. package/dist/generated/openapi-inputs.js +216 -5820
  12. package/dist/generated/openapi-outputs.js +19014 -14001
  13. package/dist/generated/route-catalog.js +80 -0
  14. package/dist/http.js +103 -41
  15. package/dist/index.js +4 -1
  16. package/dist/oauth-challenge.js +74 -0
  17. package/dist/oauth-resolver.js +58 -61
  18. package/dist/observability.js +30 -15
  19. package/dist/openapi-schema.js +175 -0
  20. package/dist/operations/catalog.js +17 -0
  21. package/dist/operations/groups/analytics.js +20 -10
  22. package/dist/operations/groups/bookings.js +43 -15
  23. package/dist/operations/groups/broadcasts.js +29 -58
  24. package/dist/operations/groups/cadences.js +3 -1
  25. package/dist/operations/groups/callEvents.js +19 -72
  26. package/dist/operations/groups/campaigns.js +28 -32
  27. package/dist/operations/groups/channels.js +23 -0
  28. package/dist/operations/groups/conversations.js +14 -25
  29. package/dist/operations/groups/insights.js +4 -0
  30. package/dist/operations/groups/integrations.js +15 -17
  31. package/dist/operations/groups/knowledge.js +5 -1
  32. package/dist/operations/groups/leads.js +22 -23
  33. package/dist/operations/groups/learning.js +13 -1
  34. package/dist/operations/groups/memory.js +4 -6
  35. package/dist/operations/groups/reports.js +1 -1
  36. package/dist/operations/groups/reviewQueue.js +17 -14
  37. package/dist/operations/groups/safety.js +1 -1
  38. package/dist/operations/groups/voice.js +0 -1
  39. package/dist/operations/groups/webhooks.js +15 -3
  40. package/dist/operations/manifest.js +7 -6
  41. package/dist/operations/registry.js +102 -108
  42. package/dist/operations.js +4 -3
  43. package/dist/optional-field.js +15 -3
  44. package/dist/page-contract.js +74 -0
  45. package/dist/pagination.js +147 -67
  46. package/dist/prompts.js +122 -97
  47. package/dist/rate-limit.js +40 -0
  48. package/dist/remote.js +145 -65
  49. package/dist/resilience.js +107 -52
  50. package/dist/resolvers.js +125 -0
  51. package/dist/scopes.js +70 -17
  52. package/dist/serialization.js +32 -22
  53. package/dist/server.js +148 -132
  54. package/dist/standard-schema.js +215 -0
  55. package/dist/task-tools/analytics.js +145 -86
  56. package/dist/task-tools/bookings.js +168 -81
  57. package/dist/task-tools/cadences.js +30 -45
  58. package/dist/task-tools/calls.js +131 -124
  59. package/dist/task-tools/inbox.js +37 -47
  60. package/dist/task-tools/leads.js +49 -77
  61. package/dist/task-tools/runtime.js +75 -15
  62. package/dist/token-verification.js +42 -0
  63. package/dist/tool-cache.js +3 -2
  64. package/dist/tool.js +151 -96
  65. package/dist/version.js +1 -1
  66. package/package.json +37 -16
  67. package/dist/openapi-zod.js +0 -335
  68. package/dist/page-shape.js +0 -62
  69. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -4,24 +4,22 @@ export const memoryOperations = [
4
4
  id: "memory.list",
5
5
  title: "List what Luca remembers",
6
6
  description: "List what Luca remembers, filtered by lead, scope, source, trust level, or free text. The fact, the reasoning, and the quoted evidence are blanked for a redacted-tier key; trust level, lead, and dates always come back.",
7
- redactedFields: [
8
- "memories[].content",
9
- "memories[].evidence",
10
- "memories[].reason",
11
- ],
12
7
  }),
13
8
  op({
14
9
  id: "memory.update",
15
10
  title: "Edit a memory",
11
+ mutatesExisting: true,
16
12
  // The corrected memory comes back in full, and a memory is a fact Luca
17
13
  // extracted from what a lead wrote.
18
14
  untrustedContent: true,
19
15
  description: "Correct one memory: edit the fact, flag it disputed when it turns out to be wrong, restore an archived one, or make it win over a conflicting memory.",
20
- redactedFields: ["content", "evidence", "reason"],
21
16
  }),
22
17
  op({
23
18
  id: "memory.setArchived",
19
+ // Answers with ids and status flags only; no lead-authored text.
20
+ untrustedContent: false,
24
21
  title: "Archive or restore memories",
22
+ mutatesExisting: true,
25
23
  description: "Archive or restore several memories at once. Archiving stops Luca using a fact while the record and its provenance survive, so it is always reversible.",
26
24
  }),
27
25
  ];
@@ -8,7 +8,7 @@ export const reportsOperations = [
8
8
  op({
9
9
  id: "reports.morning",
10
10
  title: "Read the morning report",
11
+ untrustedContent: true,
11
12
  description: "Structured 24h review-queue digest (items + counts) for the coach. No model call; summarize client-side.",
12
- redactedFields: ["items[].draftBody"],
13
13
  }),
14
14
  ];
@@ -4,44 +4,44 @@ export const reviewQueueOperations = [
4
4
  id: "reviewQueue.list",
5
5
  title: "List the review queue",
6
6
  description: "List review-queue items when the API key has review queue access.",
7
- redactedFields: [
8
- "items[].criticIssues",
9
- "items[].draftBody",
10
- "items[].draftParts",
11
- "items[].leadDisplayName",
12
- "items[].media",
13
- "items[].mediaContext",
14
- "items[].objectionVariants",
15
- "items[].qualification",
16
- "items[].safetyFindings",
17
- "items[].signals",
18
- ],
19
7
  }),
20
8
  op({
21
9
  id: "reviewQueue.explain",
22
10
  title: "Explain a queued draft",
23
11
  description: "Explain the signals behind a drafted reply: its voice-match score, any critic findings, and the qualification that motivated it.",
24
- redactedFields: ["criticIssues", "draftBody", "qualification"],
25
12
  }),
26
13
  op({
27
14
  id: "reviewQueue.approve",
15
+ // Answers with ids and status flags only; no lead-authored text.
16
+ untrustedContent: false,
28
17
  title: "Approve and send a queued draft",
29
- confirmRequired: true,
18
+ openWorld: true,
19
+ mutatesExisting: true,
20
+ confirm: "always",
30
21
  description: "Approve a pending draft for sending; dispatches through the lead's channel. Sends a real message.",
31
22
  }),
32
23
  op({
33
24
  id: "reviewQueue.reject",
25
+ // Answers with ids and status flags only; no lead-authored text.
26
+ untrustedContent: false,
34
27
  title: "Reject a queued draft",
28
+ mutatesExisting: true,
35
29
  description: "Reject a pending review-queue draft so it never sends. Stops any related booking-recovery cadence and emits review.rejected.",
36
30
  }),
37
31
  op({
38
32
  id: "reviewQueue.restore",
33
+ // Answers with ids and status flags only; no lead-authored text.
34
+ untrustedContent: false,
39
35
  title: "Restore a rejected draft",
36
+ mutatesExisting: true,
40
37
  description: "Restore a rejected review-queue item back to pending. Fails if it's a booking message whose recovery window already passed.",
41
38
  }),
42
39
  op({
43
40
  id: "reviewQueue.media.retry",
41
+ // Answers with ids and status flags only; no lead-authored text.
42
+ untrustedContent: false,
44
43
  title: "Retry a draft's failed media",
44
+ mutatesExisting: true,
45
45
  description: "Retry a failed voice-note transcription so the draft can be reviewed. Only re-transcribes; never redrafts or sends.",
46
46
  }),
47
47
  op({
@@ -51,7 +51,10 @@ export const reviewQueueOperations = [
51
51
  }),
52
52
  op({
53
53
  id: "reviewQueue.objectionVariant",
54
+ // Answers with ids and status flags only; no lead-authored text.
55
+ untrustedContent: false,
54
56
  title: "Record an objection variant choice",
57
+ mutatesExisting: true,
55
58
  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.",
56
59
  }),
57
60
  ];
@@ -3,6 +3,6 @@ export const safetyOperations = [
3
3
  op({
4
4
  id: "safety.accounts",
5
5
  title: "Check connected account safety",
6
- description: "Read each connected channel account's safety status, 24-hour send count, and human-approval window. An account inside its approval window must not be automated against.",
6
+ description: "Read each connected channel account's safety status and 24-hour send count.",
7
7
  }),
8
8
  ];
@@ -9,6 +9,5 @@ export const voiceOperations = [
9
9
  id: "voice.corpus",
10
10
  title: "Read the coach's voice corpus",
11
11
  description: "List the coach's voice corpus: the writing every draft is matched against. Entry id, source, channel, and age always come back; the writing itself is blanked for a redacted-tier key.",
12
- redactedFields: ["entries[].content"],
13
12
  }),
14
13
  ];
@@ -8,6 +8,10 @@ export const webhooksOperations = [
8
8
  op({
9
9
  id: "webhooks.subscriptions.create",
10
10
  title: "Create a webhook subscription",
11
+ openWorld: true,
12
+ mutatesExisting: true,
13
+ // Starts streaming workspace events to an outside URL.
14
+ confirm: "always",
11
15
  // Naturally idempotent: an active subscription with the same target URL
12
16
  // is updated in place (target URLs are unique per Zap/scenario webhook),
13
17
  // so this route requires no Idempotency-Key (see the matching comment on
@@ -17,6 +21,9 @@ export const webhooksOperations = [
17
21
  op({
18
22
  id: "webhooks.subscriptions.update",
19
23
  title: "Update a webhook subscription",
24
+ openWorld: true,
25
+ // Can point the event stream at a different outside URL.
26
+ confirm: "always",
20
27
  description: "Update an outbound webhook subscription.",
21
28
  }),
22
29
  op({
@@ -31,6 +38,8 @@ export const webhooksOperations = [
31
38
  op({
32
39
  id: "webhooks.testDelivery",
33
40
  title: "Send a test webhook",
41
+ openWorld: true,
42
+ confirm: "always",
34
43
  description: "Queue a canonical test webhook event.",
35
44
  }),
36
45
  op({
@@ -46,7 +55,8 @@ export const webhooksOperations = [
46
55
  op({
47
56
  id: "webhooks.deliveries.replay",
48
57
  title: "Replay a webhook delivery",
49
- confirmRequired: true,
58
+ openWorld: true,
59
+ confirm: "always",
50
60
  description: "Replay one webhook delivery without changing the event id. Re-triggers the subscriber's downstream automation.",
51
61
  }),
52
62
  op({
@@ -64,13 +74,15 @@ export const webhooksOperations = [
64
74
  op({
65
75
  id: "webhooks.events.replay",
66
76
  title: "Replay a webhook event",
67
- confirmRequired: true,
77
+ openWorld: true,
78
+ confirm: "always",
68
79
  description: "Replay one canonical webhook event. Re-triggers subscribers' downstream automations.",
69
80
  }),
70
81
  op({
71
82
  id: "webhooks.events.bulkReplay",
72
83
  title: "Replay a batch of webhook events",
73
- confirmRequired: true,
84
+ openWorld: true,
85
+ confirm: "always",
74
86
  description: "Bulk replay webhook events by type and time range. Re-triggers downstream automations at scale.",
75
87
  }),
76
88
  ];
@@ -1,6 +1,9 @@
1
1
  // Views derived from the operation table. Nothing here holds state of its own;
2
2
  // each function reads `LUCA_OPERATIONS` and reshapes it for one caller: the
3
3
  // `luca://operations` resource, the docs generator, or the registry check.
4
+ import * as Arr from "effect/Array";
5
+ import * as Order from "effect/Order";
6
+ import * as R from "effect/Record";
4
7
  import { operationAnnotations } from "../annotations.js";
5
8
  import { LUCA_OPERATIONS } from "./catalog.js";
6
9
  import { LUCA_OPERATION_GROUPS } from "./registry.js";
@@ -13,8 +16,8 @@ export function operationManifest() {
13
16
  path: operation.path,
14
17
  scopes: operation.scopes,
15
18
  idempotencyRequired: operation.idempotencyRequired,
16
- confirmRequired: operation.confirmRequired,
17
- paginates: operation.pageShape !== undefined,
19
+ confirmRequired: operation.confirm !== undefined,
20
+ paginates: operation.pageContract !== undefined,
18
21
  untrustedContent: operation.untrustedContent,
19
22
  requiredScope: operation.requiredScope,
20
23
  annotations: operationAnnotations(operation),
@@ -22,7 +25,7 @@ export function operationManifest() {
22
25
  }));
23
26
  }
24
27
  export function operationGroups() {
25
- return Object.entries(LUCA_OPERATION_GROUPS).map(([id, metadata]) => {
28
+ return R.toEntries(LUCA_OPERATION_GROUPS).map(([id, metadata]) => {
26
29
  const operations = LUCA_OPERATIONS.filter((operation) => operation.group === id);
27
30
  return {
28
31
  // SAFETY: Object.entries widens the key to `string`; the cast recovers
@@ -31,9 +34,7 @@ export function operationGroups() {
31
34
  title: metadata.title,
32
35
  description: metadata.description,
33
36
  toolCount: operations.length,
34
- scopes: [
35
- ...new Set(operations.flatMap((operation) => operation.scopes)),
36
- ].sort(),
37
+ scopes: Arr.sort(Arr.dedupe(operations.flatMap((operation) => operation.scopes)), Order.String),
37
38
  tools: operations.map((operation) => operation.toolName),
38
39
  };
39
40
  });
@@ -1,10 +1,19 @@
1
- import * as z from "zod/v4";
2
- import { OPENAPI_INPUTS } from "../generated/openapi-inputs.js";
3
- import { OPENAPI_OUTPUTS } from "../generated/openapi-outputs.js";
1
+ import * as Arr from "effect/Array";
2
+ import * as HashSet from "effect/HashSet";
3
+ import * as P from "effect/Predicate";
4
+ import * as R from "effect/Record";
5
+ import * as Result from "effect/Result";
6
+ import * as Schema from "effect/Schema";
7
+ import * as Str from "effect/String";
8
+ import { mutatesExisting } from "../annotations.js";
9
+ import { confirmInput, NonEmptyString, WORKSPACE_INPUT_FIELDS, } from "../fields.js";
10
+ import { OPENAPI_INPUT_FIELDS } from "../generated/openapi-inputs.js";
11
+ import { OPENAPI_OUTPUTS, OPENAPI_REDACTED_FIELDS, } from "../generated/openapi-outputs.js";
4
12
  import { ROUTE_CATALOG } from "../generated/route-catalog.js";
5
- import { openApiToolInputFields, openApiToolOutputFields, } from "../openapi-zod.js";
6
- import { optionalField } from "../optional-field.js";
7
- import { pageShapeFor } from "../page-shape.js";
13
+ import { openApiToolOutputFields, } from "../openapi-schema.js";
14
+ import { fieldsWhen, optionalField } from "../optional-field.js";
15
+ import { pageContractFor } from "../page-contract.js";
16
+ import { effectiveCapability } from "../scopes.js";
8
17
  export const LUCA_OPERATION_GROUPS = {
9
18
  capabilities: {
10
19
  title: "Capabilities",
@@ -60,7 +69,11 @@ export const LUCA_OPERATION_GROUPS = {
60
69
  },
61
70
  safety: {
62
71
  title: "Account Safety",
63
- description: "Per-channel account health, send pacing, and the human-approval window.",
72
+ description: "Per-channel account health and send pacing.",
73
+ },
74
+ channels: {
75
+ title: "Channels",
76
+ description: "Connected channel accounts, which channels can be connected, and what each provider supports. Read-only.",
64
77
  },
65
78
  insights: {
66
79
  title: "Insights",
@@ -91,18 +104,35 @@ export const LUCA_OPERATION_GROUPS = {
91
104
  description: "Business intelligence, funnel analytics, revenue attribution, and coach performance metrics.",
92
105
  },
93
106
  };
107
+ /** Whether this call has to carry `confirm: true` to run. */
108
+ export function needsConfirmation(gate, input) {
109
+ if (gate === undefined) {
110
+ return false;
111
+ }
112
+ return gate === "always" || gate.applies(input);
113
+ }
114
+ /**
115
+ * The tier an operation needs: what its effect asks for, raised to whatever its
116
+ * API scopes need, so the tier a tool states is the one an OAuth connection
117
+ * has to request.
118
+ */
94
119
  function requiredScopeFor(input) {
95
120
  let capability = "draft";
96
121
  if (input.method === "GET") {
97
122
  capability = "read";
98
123
  }
99
- else if (input.confirmRequired) {
124
+ else if (input.confirm === "always") {
100
125
  // Real-world side effect (sends, enrollments, replays) = operating the
101
- // live queue.
126
+ // live queue. A write gated for only some inputs keeps its route's tier:
127
+ // the gate guards the risky inputs, and the others stay open to a
128
+ // connection below queue_ops.
102
129
  capability = "queue_ops";
103
130
  }
104
131
  return {
105
- capability,
132
+ capability: effectiveCapability({
133
+ scopes: input.scopes,
134
+ requiredScope: { capability },
135
+ }),
106
136
  dataSensitivity: input.untrustedContent ? "full_content" : "redacted",
107
137
  };
108
138
  }
@@ -113,7 +143,7 @@ function requiredScopeFor(input) {
113
143
  * untrusted-content boundary so a downstream agent treats it as data, never as
114
144
  * instructions (prompt-injection defense).
115
145
  */
116
- const UNTRUSTED_CONTENT_GROUPS = new Set([
146
+ const UNTRUSTED_CONTENT_GROUPS = HashSet.fromIterable([
117
147
  "callEvents",
118
148
  "conversations",
119
149
  "leads",
@@ -127,46 +157,18 @@ const UNTRUSTED_CONTENT_GROUPS = new Set([
127
157
  /** Server-side bounded auto-pagination guard for list tools. */
128
158
  export const DEFAULT_MAX_PAGES = 10;
129
159
  const MAX_PAGES_LIMIT = 50;
130
- const OPENAPI_INPUTS_BY_ROUTE = new Map(Object.entries(OPENAPI_INPUTS));
131
- const OPENAPI_OUTPUTS_BY_ROUTE = new Map(Object.entries(OPENAPI_OUTPUTS));
132
- export const WORKSPACE_INPUT_FIELDS = {
133
- workspaceId: z
134
- .uuid()
135
- .optional()
136
- .describe("Optional Luca workspace id override."),
137
- workspaceSlug: z
138
- .string()
139
- .min(1)
140
- .optional()
141
- .describe("Optional Luca workspace slug override."),
142
- };
160
+ /** The generated records, read by a `METHOD path` key built at runtime. */
161
+ const OPENAPI_OUTPUTS_BY_ROUTE = OPENAPI_OUTPUTS;
162
+ const OPENAPI_REDACTED_FIELDS_BY_ROUTE = OPENAPI_REDACTED_FIELDS;
143
163
  const idempotencyInput = {
144
- idempotencyKey: z
145
- .string()
146
- .min(1)
147
- .optional()
148
- .describe("Stable key for safe retries. If omitted, the MCP server generates a unique key for this call."),
149
- };
150
- /**
151
- * The confirmation gate's input field, shared by the generated operation tools
152
- * and the hand-written task tools. One wording covers both because a client
153
- * sees one tool list: the effects named here are the union of what either kind
154
- * can do, not a guess at which one the reader is holding.
155
- */
156
- export const confirmInput = {
157
- confirm: z
158
- .boolean()
159
- .optional()
160
- .describe("This tool has a real-world side effect: it messages a real lead, starts outbound touches, or re-triggers downstream automations. Pass confirm: true to proceed; the call is rejected without it."),
164
+ idempotencyKey: Schema.optionalKey(NonEmptyString.annotate({
165
+ description: "Stable key for safe retries. If omitted, the MCP server generates a unique key for this call.",
166
+ })),
161
167
  };
162
168
  const paginationInput = {
163
- maxPages: z
164
- .number()
165
- .int()
166
- .min(1)
167
- .max(MAX_PAGES_LIMIT)
168
- .optional()
169
- .describe(`Max pages the server auto-fetches and merges (default ${DEFAULT_MAX_PAGES}, hard cap ${MAX_PAGES_LIMIT}). If more remain, the result is marked truncated with a nextCursor to continue.`),
169
+ maxPages: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: MAX_PAGES_LIMIT })).annotate({
170
+ description: `Max pages the server auto-fetches and merges (default ${DEFAULT_MAX_PAGES}, hard cap ${MAX_PAGES_LIMIT}). If more remain, the result is marked truncated with a nextCursor to continue.`,
171
+ })),
170
172
  };
171
173
  /**
172
174
  * Fold the fields every operation tool shares, meaning query, body, idempotency
@@ -185,52 +187,36 @@ function commonInput(input) {
185
187
  },
186
188
  };
187
189
  }
190
+ /** The `{name}` placeholders in a route path, in order. */
191
+ function pathParamNames(path) {
192
+ return Arr.filterMap(Array.from(path.matchAll(/\{([^}]+)\}/g)), ([, key]) => key === undefined ? Result.failVoid : Result.succeed(key));
193
+ }
188
194
  function pathParams(input, path) {
189
- const params = {};
190
- for (const [, key] of path.matchAll(/\{([^}]+)\}/g)) {
191
- // The capture group requires at least one character, so a match always
192
- // carries a non-empty key. This guard exists to satisfy the compiler's
193
- // view of `RegExpMatchArray`, and no path string can reach it.
194
- // Stryker disable next-line ConditionalExpression,BlockStatement: unreachable, the capture group cannot match empty
195
- if (!key) {
196
- continue;
197
- }
198
- const value = z.string().safeParse(input[key]);
199
- if (value.success) {
200
- params[key] = value.data;
201
- }
202
- }
203
- return params;
195
+ return R.fromEntries(Arr.filterMap(pathParamNames(path), (key) => {
196
+ const value = input[key];
197
+ return P.isString(value)
198
+ ? Result.succeed([key, value])
199
+ : Result.failVoid;
200
+ }));
204
201
  }
205
202
  function inputSchemaFor(input) {
206
- const fields = openApiToolInputFields({
207
- spec: OPENAPI_INPUTS_BY_ROUTE.get(`${input.method} ${input.path}`),
208
- workspaceInput: WORKSPACE_INPUT_FIELDS,
209
- idempotencyInput,
210
- idempotencyRequired: input.idempotencyRequired,
211
- });
212
- if (input.confirmRequired) {
213
- Object.assign(fields, confirmInput);
214
- }
215
- if (input.paginates) {
216
- Object.assign(fields, paginationInput);
217
- }
218
- return fields;
203
+ return {
204
+ ...WORKSPACE_INPUT_FIELDS,
205
+ ...fieldsWhen(input.idempotencyRequired, idempotencyInput),
206
+ ...OPENAPI_INPUT_FIELDS[`${input.method} ${input.path}`],
207
+ ...fieldsWhen(input.confirm !== undefined, confirmInput),
208
+ ...fieldsWhen(input.paginates, paginationInput),
209
+ };
219
210
  }
220
211
  /**
221
- * Build an operation's output schema and, if it declares `redactedFields`
222
- * (see the `op()` parameter doc), attach an
223
- * `x-luca-redacted-fields` marker to `result` so an MCP client can tell those
224
- * fields may arrive blanked for a `redacted`-tier key. `.meta()` is metadata
225
- * only. It never affects `.parse()` or runtime validation, and it is defined
226
- * on the classic `z.ZodType` interface, not the core `$ZodType` that
227
- * `ZodFields` values are typed as. The cast is safe because every value
228
- * here is built by `schemaToZod()` in `openapi-zod.ts`, which only ever
229
- * constructs classic zod instances.
212
+ * Build an operation's output schema and, when `redactedFields` is non-empty,
213
+ * attach an `x-luca-redacted-fields` marker to `result` so an MCP client can
214
+ * tell those fields may arrive blanked for a `redacted`-tier key. The marker is
215
+ * an annotation only and never affects validation.
230
216
  */
231
217
  export function outputSchemaFor(input) {
232
218
  const fields = openApiToolOutputFields({
233
- body: OPENAPI_OUTPUTS_BY_ROUTE.get(`${input.method} ${input.path}`),
219
+ body: OPENAPI_OUTPUTS_BY_ROUTE[`${input.method} ${input.path}`],
234
220
  untrustedContent: input.untrustedContent,
235
221
  });
236
222
  // This guard also runs while `LUCA_OPERATIONS` is built at module
@@ -241,14 +227,13 @@ export function outputSchemaFor(input) {
241
227
  // registers as a survivor. The branch itself is real and covered: the absent,
242
228
  // empty, and populated cases all assert on the marker.
243
229
  // Stryker disable next-line LogicalOperator,BooleanLiteral: crashes at import, leaving no test to attribute the kill to
244
- if (!input.redactedFields || input.redactedFields.length === 0) {
230
+ if (!input.redactedFields || Arr.isReadonlyArrayEmpty(input.redactedFields)) {
245
231
  return fields;
246
232
  }
233
+ const result = fields.result ?? Schema.Unknown;
247
234
  return {
248
235
  ...fields,
249
- // openApiToolOutputFields always sets `result`; ZodRawShape's index
250
- // signature cannot express that invariant.
251
- result: z.any().pipe(fields.result).meta({
236
+ result: result.annotate({
252
237
  "x-luca-redacted-fields": input.redactedFields,
253
238
  }),
254
239
  };
@@ -267,6 +252,15 @@ function operationGroupForId(id) {
267
252
  const [group] = id.split(".");
268
253
  return group;
269
254
  }
255
+ /**
256
+ * The MCP tool name for an operation id: `luca_` plus the id in snake_case,
257
+ * so `analytics.speedImpact` becomes `luca_analytics_speed_impact`. Task
258
+ * tools are named the same way, and one casing across the list is what
259
+ * clients and models see.
260
+ */
261
+ function toolNameFor(id) {
262
+ return `luca_${id.split(".").map(Str.camelToSnake).join("_")}`;
263
+ }
270
264
  /**
271
265
  * Declare one 1:1 operation tool. The route's id says which allowlisted route
272
266
  * it is; {@link ROUTE_CATALOG} answers with the method, path, scopes, and
@@ -276,36 +270,36 @@ function operationGroupForId(id) {
276
270
  * ({@link inputSchemaFor}), the required scope tier ({@link requiredScopeFor}),
277
271
  * the untrusted-content boundary, and a `buildRequest` that maps tool input to
278
272
  * an HTTP request. What stays here is what only the tool surface knows: the
279
- * title, the agent-facing description, the confirmation gate, and which fields
280
- * can come back blanked. Task tools (composed, intent-level) are declared
281
- * separately in `task-tools.ts`.
273
+ * title, the agent-facing description, and the confirmation gate. Which fields
274
+ * can come back blanked is read off the route's `sensitive()` markers in
275
+ * {@link OPENAPI_REDACTED_FIELDS}. Task tools (composed, intent-level) are
276
+ * declared separately in `task-tools.ts`.
282
277
  */
283
278
  export function op(input) {
284
279
  const route = ROUTE_CATALOG[input.id];
285
- // SAFETY: `ROUTE_CATALOG` is generated from the allowlist and its methods are
286
- // the ones the public API serves; the snapshot carries them as plain strings.
287
- const method = route.method;
288
- const { path, scopes, idempotencyRequired } = route;
289
- const confirmRequired = input.confirmRequired ?? false;
290
- const pageShape = pageShapeFor(method, path);
280
+ const { method, path, scopes, idempotencyRequired } = route;
281
+ const mutatesExistingRoute = input.mutatesExisting ?? mutatesExisting(method);
282
+ const pageContract = pageContractFor(method, path);
291
283
  const group = operationGroupForId(input.id);
292
- const untrustedContent = input.untrustedContent ??
293
- (method === "GET" && UNTRUSTED_CONTENT_GROUPS.has(group));
284
+ const untrustedContent = input.untrustedContent ?? HashSet.has(UNTRUSTED_CONTENT_GROUPS, group);
294
285
  const operation = {
295
286
  id: input.id,
296
287
  group,
297
- toolName: `luca_${input.id.replaceAll(".", "_")}`,
288
+ toolName: toolNameFor(input.id),
298
289
  title: input.title,
299
290
  method,
300
291
  path,
301
292
  scopes,
293
+ mutatesExisting: mutatesExistingRoute,
302
294
  idempotencyRequired,
303
- confirmRequired,
304
- ...optionalField("pageShape", pageShape),
295
+ ...optionalField("confirm", input.confirm),
296
+ openWorld: input.openWorld ?? false,
297
+ ...optionalField("pageContract", pageContract),
305
298
  untrustedContent,
306
299
  requiredScope: requiredScopeFor({
307
300
  method,
308
- confirmRequired,
301
+ scopes,
302
+ confirm: input.confirm,
309
303
  untrustedContent,
310
304
  }),
311
305
  description: input.description,
@@ -313,14 +307,14 @@ export function op(input) {
313
307
  method,
314
308
  path,
315
309
  idempotencyRequired,
316
- confirmRequired,
317
- paginates: pageShape !== undefined,
310
+ confirm: input.confirm,
311
+ paginates: pageContract !== undefined,
318
312
  }),
319
313
  outputSchema: outputSchemaFor({
320
314
  method,
321
315
  path,
322
316
  untrustedContent,
323
- ...optionalField("redactedFields", input.redactedFields),
317
+ ...optionalField("redactedFields", OPENAPI_REDACTED_FIELDS_BY_ROUTE[`${method} ${path}`]),
324
318
  }),
325
319
  buildRequest: (toolInput) => ({
326
320
  pathParams: pathParams(toolInput, path),
@@ -2,10 +2,11 @@
2
2
  // job under `./operations/`: `registry.ts` holds the types and the `op()`
3
3
  // machinery, `catalog.ts` holds the operation table, and `manifest.ts` holds
4
4
  // the views derived from it. What a client is told about a tool lives in
5
- // `../annotations.ts`, one level up, because both kinds of tool ask for it.
5
+ // `../annotations.ts` and `../tool.ts`, one level up, because both kinds of
6
+ // tool ask for it.
6
7
  // This file names each export rather than re-exporting with `export *`,
7
8
  // because a star barrel pulls the whole dependency graph into every importer.
8
9
  // The lint rule that forbids it is measuring real load cost, not style.
9
- export { LUCA_OPERATIONS } from "./operations/catalog.js";
10
+ export { LUCA_OPERATIONS, operationById } from "./operations/catalog.js";
10
11
  export { operationGroups, operationManifest } from "./operations/manifest.js";
11
- export { confirmInput, DEFAULT_MAX_PAGES, outputSchemaFor, WORKSPACE_INPUT_FIELDS, } from "./operations/registry.js";
12
+ export { DEFAULT_MAX_PAGES, needsConfirmation, outputSchemaFor, } from "./operations/registry.js";
@@ -1,12 +1,24 @@
1
- import { Option } from "effect";
1
+ import * as Option from "effect/Option";
2
+ import * as R from "effect/Record";
2
3
  export function optionalField(key, value) {
3
- return Option.fromNullable(value).pipe(Option.match({
4
+ return Option.fromNullishOr(value).pipe(Option.match({
4
5
  // Returning `undefined` here instead of `{}` is indistinguishable at
5
6
  // every call site: the result is only ever spread, and spreading either
6
7
  // value contributes no keys. The empty object is written out because it
7
8
  // matches the declared return type.
8
9
  // Stryker disable next-line ArrowFunction: `...undefined` and `...{}` both contribute nothing
9
10
  onNone: () => ({}),
10
- onSome: (present) => Object.fromEntries([[key, present]]),
11
+ onSome: (present) => R.fromEntries([[key, present]]),
11
12
  }));
12
13
  }
14
+ /**
15
+ * Spreads the fields `build` makes from a value, or nothing when the value is
16
+ * absent. {@link optionalField} covers the single-key case.
17
+ */
18
+ export function optionalFields(value, build) {
19
+ return Option.fromNullishOr(value).pipe(Option.match({ onNone: () => ({}), onSome: build }));
20
+ }
21
+ /** Spreads `fields` when `include` holds, or nothing when it does not. */
22
+ export function fieldsWhen(include, fields) {
23
+ return include ? fields : {};
24
+ }