@setluca/mcp 0.1.1 → 0.2.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 (60) hide show
  1. package/README.md +110 -42
  2. package/dist/annotations.js +45 -0
  3. package/dist/completions.js +90 -0
  4. package/dist/config.js +55 -29
  5. package/dist/errors.js +59 -14
  6. package/dist/generated/openapi-inputs.js +4324 -1429
  7. package/dist/generated/openapi-outputs.js +21992 -8516
  8. package/dist/generated/route-catalog.js +1480 -0
  9. package/dist/http.js +95 -103
  10. package/dist/index.js +16 -10
  11. package/dist/oauth-resolver.js +58 -36
  12. package/dist/observability.js +11 -3
  13. package/dist/openapi-zod.js +129 -65
  14. package/dist/operations/catalog.js +50 -0
  15. package/dist/operations/groups/analytics.js +122 -0
  16. package/dist/operations/groups/bookings.js +189 -0
  17. package/dist/operations/groups/broadcasts.js +106 -0
  18. package/dist/operations/groups/cadences.js +14 -0
  19. package/dist/operations/groups/callEvents.js +131 -0
  20. package/dist/operations/groups/campaigns.js +132 -0
  21. package/dist/operations/groups/capabilities.js +8 -0
  22. package/dist/operations/groups/coach.js +43 -0
  23. package/dist/operations/groups/conversations.js +48 -0
  24. package/dist/operations/groups/insights.js +33 -0
  25. package/dist/operations/groups/integrations.js +80 -0
  26. package/dist/operations/groups/knowledge.js +34 -0
  27. package/dist/operations/groups/leads.js +101 -0
  28. package/dist/operations/groups/learning.js +24 -0
  29. package/dist/operations/groups/memory.js +27 -0
  30. package/dist/operations/groups/reports.js +14 -0
  31. package/dist/operations/groups/reviewQueue.js +57 -0
  32. package/dist/operations/groups/safety.js +8 -0
  33. package/dist/operations/groups/usage.js +13 -0
  34. package/dist/operations/groups/voice.js +14 -0
  35. package/dist/operations/groups/webhooks.js +76 -0
  36. package/dist/operations/manifest.js +40 -0
  37. package/dist/operations/registry.js +331 -0
  38. package/dist/operations.js +11 -780
  39. package/dist/optional-field.js +12 -0
  40. package/dist/page-shape.js +47 -0
  41. package/dist/pagination.js +164 -0
  42. package/dist/prompts.js +63 -8
  43. package/dist/provenance.js +18 -2
  44. package/dist/remote.js +80 -49
  45. package/dist/resilience.js +117 -0
  46. package/dist/serialization.js +36 -0
  47. package/dist/server.js +256 -281
  48. package/dist/task-tools/analytics.js +168 -0
  49. package/dist/task-tools/bookings.js +154 -0
  50. package/dist/task-tools/cadences.js +65 -0
  51. package/dist/task-tools/calls.js +202 -0
  52. package/dist/task-tools/inbox.js +74 -0
  53. package/dist/task-tools/leads.js +123 -0
  54. package/dist/task-tools/runtime.js +92 -0
  55. package/dist/task-tools.js +21 -0
  56. package/dist/tool-cache.js +39 -0
  57. package/dist/tool.js +228 -0
  58. package/dist/tsconfig.tsbuildinfo +1 -1
  59. package/dist/version.js +14 -0
  60. package/package.json +36 -33
@@ -0,0 +1,123 @@
1
+ import * as z from "zod/v4";
2
+ import { confirmInput, WORKSPACE_INPUT_FIELDS } from "../operations.js";
3
+ import { optionalField } from "../optional-field.js";
4
+ import { numberInput, stringInput, taskRequest, textBody, } from "./runtime.js";
5
+ const findLeads = {
6
+ name: "luca_find_leads",
7
+ title: "Find leads",
8
+ description: "Search the coach's leads by free-text query and optional channel. Use this to locate a lead before drafting, booking, or flagging. Example: call with { query: \"maria instagram\" } to find Maria's Instagram lead.",
9
+ inputSchema: {
10
+ ...WORKSPACE_INPUT_FIELDS,
11
+ query: z
12
+ .string()
13
+ .min(1)
14
+ .describe("Free-text search: name, handle, or keyword."),
15
+ channel: z
16
+ .string()
17
+ .optional()
18
+ .describe("Optional channel filter (telegram, instagram, messenger, whatsapp)."),
19
+ limit: z
20
+ .number()
21
+ .int()
22
+ .min(1)
23
+ .max(50)
24
+ .optional()
25
+ .describe("Max results (default 10)."),
26
+ },
27
+ readOnly: true,
28
+ untrustedContent: true,
29
+ composes: ["leads.list"],
30
+ run: (api, operationById, input) => api.request(taskRequest(operationById("leads.list"), input, {
31
+ query: {
32
+ q: stringInput(input.query),
33
+ ...optionalField("channel", input.channel),
34
+ limit: numberInput(input.limit, 10),
35
+ },
36
+ })),
37
+ };
38
+ const flagForHuman = {
39
+ name: "luca_flag_for_human",
40
+ title: "Flag a lead for human attention",
41
+ description: 'Add a note to a lead, tagged [needs-human], that the coach sees on the lead\'s timeline. This is an ordinary note with a prefix — it does not route, alert, or reprioritize anything. Use when a conversation needs judgment an agent should not exercise (payment claims, distress, legal), and tell the coach directly if it is urgent. Example: call with { leadId, reason: "lead mentioned a refund dispute" }.',
42
+ inputSchema: {
43
+ ...WORKSPACE_INPUT_FIELDS,
44
+ leadId: z.string().min(1).describe("The lead to flag."),
45
+ reason: z
46
+ .string()
47
+ .min(1)
48
+ .describe("Why a human should look — shown to the coach verbatim."),
49
+ idempotencyKey: z
50
+ .string()
51
+ .optional()
52
+ .describe("Reuse the same key when retrying this exact flag."),
53
+ },
54
+ readOnly: false,
55
+ untrustedContent: false,
56
+ composes: ["leads.notes.add"],
57
+ run: (api, operationById, input) => api.request(taskRequest(operationById("leads.notes.add"), input, {
58
+ pathParams: { id: stringInput(input.leadId) },
59
+ body: { body: `[needs-human] ${stringInput(input.reason)}` },
60
+ })),
61
+ };
62
+ const draftReply = {
63
+ name: "luca_draft_reply",
64
+ title: "Draft a reply for a lead",
65
+ description: 'Draft a voice-matched reply for a lead on demand and queue it for review — the middle step of the reply loop (triage → draft → approve). Optional guidance steers tone or content. Nothing is sent; approve it with luca_approve_and_send. Example: call with { leadId, guidance: "keep it short and warm" }.',
66
+ inputSchema: {
67
+ ...WORKSPACE_INPUT_FIELDS,
68
+ leadId: z.string().min(1).describe("The lead to draft a reply for."),
69
+ guidance: z
70
+ .string()
71
+ .min(1)
72
+ .max(500)
73
+ .optional()
74
+ .describe("Optional steer for tone or content of the draft."),
75
+ idempotencyKey: z
76
+ .string()
77
+ .optional()
78
+ .describe("Reuse the same key when retrying this exact draft."),
79
+ },
80
+ readOnly: false,
81
+ untrustedContent: false,
82
+ composes: ["leads.draft"],
83
+ run: (api, operationById, input) => api.request(taskRequest(operationById("leads.draft"), input, {
84
+ pathParams: { id: stringInput(input.leadId) },
85
+ body: textBody("guidance", input.guidance),
86
+ })),
87
+ };
88
+ const approveAndSend = {
89
+ name: "luca_approve_and_send",
90
+ title: "Approve a drafted reply and send it",
91
+ description: "Approve a pending review-queue draft and dispatch it through the lead's channel. THIS SENDS A REAL MESSAGE TO A REAL PERSON — only call it after a human has reviewed and intends to send. Pass confirm: true to proceed. Optional finalBody replaces the draft text before sending. Example: { reviewQueueId, confirm: true }.",
92
+ inputSchema: {
93
+ ...WORKSPACE_INPUT_FIELDS,
94
+ reviewQueueId: z
95
+ .string()
96
+ .min(1)
97
+ .describe("The review-queue item to approve."),
98
+ finalBody: z
99
+ .string()
100
+ .min(1)
101
+ .optional()
102
+ .describe("Optional edited body to send instead of the drafted text."),
103
+ ...confirmInput,
104
+ idempotencyKey: z
105
+ .string()
106
+ .optional()
107
+ .describe("Reuse the same key when retrying this exact approval."),
108
+ },
109
+ readOnly: false,
110
+ untrustedContent: false,
111
+ confirmRequired: true,
112
+ composes: ["reviewQueue.approve"],
113
+ run: (api, operationById, input) => api.request(taskRequest(operationById("reviewQueue.approve"), input, {
114
+ pathParams: { id: stringInput(input.reviewQueueId) },
115
+ body: textBody("finalBody", input.finalBody),
116
+ })),
117
+ };
118
+ export const leadTaskTools = [
119
+ findLeads,
120
+ flagForHuman,
121
+ draftReply,
122
+ approveAndSend,
123
+ ];
@@ -0,0 +1,92 @@
1
+ import { Effect, Option, Schema } from "effect";
2
+ import * as z from "zod/v4";
3
+ import { formatError } from "../errors.js";
4
+ import { optionalField } from "../optional-field.js";
5
+ function workspaceOverride(input) {
6
+ const override = {
7
+ ...optionalField("workspaceId", input.workspaceId),
8
+ ...optionalField("workspaceSlug", input.workspaceSlug),
9
+ };
10
+ // An override naming neither field is no override at all, and sending an
11
+ // empty one would pin the request to nothing.
12
+ if (Object.keys(override).length === 0) {
13
+ return;
14
+ }
15
+ return override;
16
+ }
17
+ /**
18
+ * Assemble the request a task tool sends: the resolved operation plus its
19
+ * path/query/body, with the workspace override and idempotency key folded in
20
+ * the same way for every tool. This is the task-tool analogue of operations.ts
21
+ * `commonInput` — one seam owns "how a task request is built", so a tool body
22
+ * reads as the fields it maps, not the plumbing. Pass `body: undefined` to omit
23
+ * the body entirely (an absent optional field, never `{ body: undefined }`).
24
+ */
25
+ export function taskRequest(operation, input, parts = {}) {
26
+ return {
27
+ operation,
28
+ ...optionalField("pathParams", parts.pathParams),
29
+ ...optionalField("query", parts.query),
30
+ ...optionalField("body", parts.body),
31
+ ...optionalField("idempotencyKey", input.idempotencyKey),
32
+ ...optionalField("workspace", workspaceOverride(input)),
33
+ };
34
+ }
35
+ /**
36
+ * The only part of a listing page a composing tool reads for itself: the ids of
37
+ * the items it then fans out over. Decoding that alone leaves the raw page
38
+ * intact for the caller — it is returned exactly as the API sent it — while the
39
+ * ids the tool acts on are validated rather than asserted.
40
+ */
41
+ const ListingIds = Schema.Struct({
42
+ items: Schema.optional(Schema.Array(Schema.Struct({ id: Schema.optional(Schema.String) }))),
43
+ });
44
+ const decodeListingIds = Schema.decodeUnknownOption(ListingIds);
45
+ /**
46
+ * Ids of the first `limit` items on a listing page. A page whose fields we
47
+ * cannot read, or whose items carry no id, contributes nothing — the tool still
48
+ * returns the page itself rather than failing over a fan-out it cannot do.
49
+ */
50
+ export function listingIds(page, limit) {
51
+ // Both fallbacks below are empty arrays, and substituting a one-item array
52
+ // for either is an equivalent mutation rather than an untested branch: a
53
+ // substituted item carries no `id`, so the `filter` at the end of this
54
+ // function drops it and the result is the same empty id list. Substituting
55
+ // `undefined` for the outer fallback is a real change and is covered by the
56
+ // undecodable-page test.
57
+ const items = decodeListingIds(page).pipe(
58
+ // Stryker disable next-line ArrayDeclaration: a substituted item has no id and is filtered out below
59
+ Option.map((decoded) => decoded.items ?? []),
60
+ // Stryker disable next-line ArrayDeclaration: a substituted item has no id and is filtered out below
61
+ Option.getOrElse(() => []));
62
+ return items
63
+ .slice(0, limit)
64
+ .map((item) => item.id)
65
+ .filter((id) => id !== undefined);
66
+ }
67
+ /**
68
+ * A request body carrying a single text field, or no body at all when the input
69
+ * is absent or blank — sending an empty string would mean something different to
70
+ * the API than leaving the field off.
71
+ */
72
+ export function textBody(key, value) {
73
+ return Option.fromNullable(value).pipe(Option.map(String), Option.filter((text) => text.length > 0), Option.map((text) => ({ [key]: text })), Option.getOrUndefined);
74
+ }
75
+ /** Reads a numeric tool input, falling back when it is absent or not a number. */
76
+ export function numberInput(value, fallback) {
77
+ const parsed = z.number().safeParse(value);
78
+ return parsed.success ? parsed.data : fallback;
79
+ }
80
+ /** Required string arguments have already passed the registered tool schema. */
81
+ export function stringInput(value) {
82
+ return z.string().parse(value);
83
+ }
84
+ /**
85
+ * A write whose failure must not cancel the writes around it. A tool that files
86
+ * two independent reports has to keep the second one reachable when the first
87
+ * fails, so the failure comes back as a field the agent can read instead of a
88
+ * dead tool call that invites a retry of the half that already landed.
89
+ */
90
+ export function independentWrite(errorKey, effect) {
91
+ return Effect.catchAll(effect, (error) => Effect.succeed({ [errorKey]: formatError(error) }));
92
+ }
@@ -0,0 +1,21 @@
1
+ // The task-tool registry: composed, intent-level entry points layered over the
2
+ // generated 1:1 operation tools. It is deliberately NOT `LUCA_OPERATIONS`,
3
+ // whose entries the OpenAPI drift checker matches one-to-one against REST
4
+ // routes — a task tool has no route of its own. Each tool hand-writes its
5
+ // input fields and composes existing operations through `LucaApi.request`, and
6
+ // they are split one file per domain under `task-tools/`. The shape they all
7
+ // share, and the request plumbing they all use, lives in `task-tools/runtime.ts`.
8
+ import { analyticsTaskTools } from "./task-tools/analytics.js";
9
+ import { bookingTaskTools } from "./task-tools/bookings.js";
10
+ import { cadenceTaskTools } from "./task-tools/cadences.js";
11
+ import { callTaskTools } from "./task-tools/calls.js";
12
+ import { inboxTaskTools } from "./task-tools/inbox.js";
13
+ import { leadTaskTools } from "./task-tools/leads.js";
14
+ export const LUCA_TASK_TOOLS = [
15
+ ...inboxTaskTools,
16
+ ...leadTaskTools,
17
+ ...bookingTaskTools,
18
+ ...callTaskTools,
19
+ ...cadenceTaskTools,
20
+ ...analyticsTaskTools,
21
+ ];
@@ -0,0 +1,39 @@
1
+ function cache() {
2
+ let entries = new WeakMap();
3
+ return {
4
+ get: (key, build) => {
5
+ const cached = entries.get(key);
6
+ if (cached !== undefined) {
7
+ return cached;
8
+ }
9
+ const built = build();
10
+ entries.set(key, built);
11
+ return built;
12
+ },
13
+ clear: () => {
14
+ entries = new WeakMap();
15
+ },
16
+ };
17
+ }
18
+ /**
19
+ * Only the reset of each cache is registered, not the cache itself: a list of
20
+ * caches would have to name a key and value type the entries do not share, and
21
+ * the only thing this module needs from them all is the same nullary call.
22
+ */
23
+ const resets = [];
24
+ /** A cache that {@link clearToolBuildCache} knows how to empty. */
25
+ export function buildCache() {
26
+ const created = cache();
27
+ resets.push(created.clear);
28
+ return created;
29
+ }
30
+ /**
31
+ * Drops every build the caches hold. Only tests call this: a definition cached
32
+ * under one version of the code would otherwise be reused after the code
33
+ * changed underneath it, which is exactly what a mutation run does.
34
+ */
35
+ export function clearToolBuildCache() {
36
+ for (const reset of resets) {
37
+ reset();
38
+ }
39
+ }
package/dist/tool.js ADDED
@@ -0,0 +1,228 @@
1
+ import { Duration, Effect, Layer } from "effect";
2
+ import * as z from "zod/v4";
3
+ import { mutatesExisting, toolAnnotations } from "./annotations.js";
4
+ import { formatError, LucaToolInputError } from "./errors.js";
5
+ import { LucaApi } from "./http.js";
6
+ import { logAgentSurfaceEvent } from "./observability.js";
7
+ import { optionalField } from "./optional-field.js";
8
+ import { paginatedProgram } from "./pagination.js";
9
+ import { provenanceFields, UNTRUSTED_CONTENT_NOTE } from "./provenance.js";
10
+ import { JsonObjectSchema, toJsonText, } from "./serialization.js";
11
+ import { buildCache } from "./tool-cache.js";
12
+ /**
13
+ * Each derivation below is cached on the identity of the catalog entry it comes
14
+ * from, because nothing in a `RegisterableTool` varies by caller and the Worker
15
+ * builds a fresh server per request. See `tool-cache.ts` for what that buys and
16
+ * how a test empties it.
17
+ */
18
+ const operationTools = buildCache();
19
+ const taskTools = buildCache();
20
+ const objectSchemas = buildCache();
21
+ /** A tool's fields as one Zod object, built once per fields object. */
22
+ function objectSchema(fields) {
23
+ return objectSchemas.get(fields, () => z.object(fields));
24
+ }
25
+ export function fromOperation(operation) {
26
+ return operationTools.get(operation, () => buildFromOperation(operation));
27
+ }
28
+ function buildFromOperation(operation) {
29
+ return {
30
+ name: operation.toolName,
31
+ title: operation.title,
32
+ description: operationDescription(operation),
33
+ inputSchema: operation.inputSchema,
34
+ outputSchema: operation.outputSchema,
35
+ readOnly: operation.method === "GET",
36
+ mutatesExisting: mutatesExisting(operation.method),
37
+ confirmRequired: operation.confirmRequired,
38
+ idempotencyRequired: operation.idempotencyRequired,
39
+ untrustedContent: operation.untrustedContent,
40
+ run: (input) => operationProgram(operation, input),
41
+ };
42
+ }
43
+ /**
44
+ * `operationById` arrives as a parameter rather than an import: the lookup map
45
+ * is built beside the server, and importing it here would close a cycle
46
+ * between this module and the one that registers with it.
47
+ */
48
+ export function fromTaskTool(tool, operationById) {
49
+ return taskTools.get(tool, () => buildFromTaskTool(tool, operationById));
50
+ }
51
+ function buildFromTaskTool(tool, operationById) {
52
+ return {
53
+ name: tool.name,
54
+ title: tool.title,
55
+ description: tool.description,
56
+ inputSchema: tool.inputSchema,
57
+ outputSchema: undefined,
58
+ readOnly: tool.readOnly,
59
+ // No task tool maps onto a single mutating route. A destructive one is
60
+ // destructive because it reaches a real lead, which `confirmRequired`
61
+ // already states.
62
+ mutatesExisting: false,
63
+ confirmRequired: tool.confirmRequired === true,
64
+ // A task tool sends several requests, so no single key describes the call.
65
+ // Each constituent write carries its own, minted by the HTTP client.
66
+ idempotencyRequired: false,
67
+ untrustedContent: tool.untrustedContent,
68
+ run: (input) => Effect.gen(function* () {
69
+ const api = yield* LucaApi;
70
+ return yield* tool.run(api, operationById, input);
71
+ }),
72
+ };
73
+ }
74
+ /**
75
+ * `toolset` arrives as a parameter rather than a field on the tool: the same
76
+ * `LucaOperation` serves both surfaces, so a tool has no business claiming to
77
+ * know which one registered it.
78
+ */
79
+ export function registerTool(server, tool, lucaLayer, toolset) {
80
+ server.registerTool(tool.name, {
81
+ title: tool.title,
82
+ description: tool.description,
83
+ inputSchema: objectSchema(tool.inputSchema),
84
+ ...optionalField("outputSchema", tool.outputSchema === undefined
85
+ ? undefined
86
+ : objectSchema(tool.outputSchema)),
87
+ annotations: toolAnnotations(tool),
88
+ }, (input) => Effect.runPromise(toolResult(tool, lucaLayer, toolset, input)));
89
+ }
90
+ /**
91
+ * The call's arguments as a JSON object, failing into the error channel rather
92
+ * than throwing. The SDK validates against the tool's own input schema before
93
+ * the handler runs, so a real client never lands here; the point is that no
94
+ * code ahead of `runPromise` can throw where the error channel cannot see it.
95
+ */
96
+ function parseToolInput(input) {
97
+ return Effect.try({
98
+ try: () => JsonObjectSchema.parse(input),
99
+ catch: () => new LucaToolInputError({
100
+ message: "Tool arguments must be a JSON object",
101
+ }),
102
+ });
103
+ }
104
+ function ended(outcome, result) {
105
+ return { outcome, result };
106
+ }
107
+ /**
108
+ * A tool call's result, confirmation gate included. Every way the call can end —
109
+ * unreadable arguments, a rejected confirmation, a failed request, a thrown
110
+ * defect while the request was being built — leaves as a tool result, so
111
+ * nothing reaches the transport as an unhandled rejection.
112
+ *
113
+ * The log line sits after the defect handler, so a call produces exactly one
114
+ * line whichever way it ended.
115
+ */
116
+ function toolResult(tool, lucaLayer, toolset, input) {
117
+ return parseToolInput(input).pipe(Effect.flatMap((parsed) => gatedResult(tool, lucaLayer, parsed)), Effect.catchAll((error) => Effect.succeed(ended("error", asErrorResult(error)))), Effect.catchAllDefect((defect) => Effect.succeed(ended("defect", asErrorResult(defect instanceof Error ? defect : new Error(String(defect)))))), Effect.timed, Effect.tap(([elapsed, ended_]) => Effect.sync(() => {
118
+ // The defect's own message never lands here: it can carry arbitrary
119
+ // thrown text derived from the call's input. The client still sees it
120
+ // in the tool result.
121
+ logAgentSurfaceEvent("mcp.tool.called", {
122
+ toolName: tool.name,
123
+ toolset,
124
+ outcome: ended_.outcome,
125
+ durationMs: Math.round(Duration.toMillis(elapsed)),
126
+ });
127
+ })), Effect.map(([, ended_]) => ended_.result));
128
+ }
129
+ /** One tool call past the confirmation gate, or the gate's own refusal. */
130
+ function gatedResult(tool, lucaLayer, input) {
131
+ if (tool.confirmRequired && input.confirm !== true) {
132
+ return Effect.succeed(ended("error", confirmationError(tool.name)));
133
+ }
134
+ return runTool(tool, lucaLayer, input);
135
+ }
136
+ /** One confirmed tool call, run to a success or an error tool result. */
137
+ function runTool(tool, lucaLayer, input) {
138
+ return tool.run(input).pipe(Effect.provide(lucaLayer), Effect.match({
139
+ onFailure: (error) => ended("error", asErrorResult(error)),
140
+ onSuccess: (result) => ended("success", asSuccessResult(tool, result)),
141
+ }));
142
+ }
143
+ /** The Effect program that runs an operation, auto-paginating list tools. */
144
+ export function operationProgram(operation, toolInput) {
145
+ const baseRequest = operation.buildRequest(toolInput);
146
+ const { pageShape } = operation;
147
+ if (pageShape) {
148
+ return paginatedProgram(operation, pageShape, baseRequest, toolInput.maxPages);
149
+ }
150
+ // An operation with no page shape has no pagination to follow, so it is one
151
+ // request, sent as-is.
152
+ return Effect.gen(function* () {
153
+ const api = yield* LucaApi;
154
+ return yield* api.request({ operation, ...baseRequest });
155
+ });
156
+ }
157
+ /**
158
+ * A tool's successful result. A response that can carry external-user text is
159
+ * flagged untrusted on both halves — the structured `provenance.untrusted`
160
+ * field (spoof-resistant) and a human-facing preamble on the text — so a
161
+ * downstream agent treats embedded text as data, not instructions. What the
162
+ * structured half attaches is `provenanceFields`, shared with the resource
163
+ * reads in `server.ts`.
164
+ */
165
+ function asSuccessResult(tool, result) {
166
+ const text = toJsonText(result);
167
+ return {
168
+ content: [
169
+ {
170
+ type: "text",
171
+ text: tool.untrustedContent
172
+ ? `[UNTRUSTED CONTENT] ${UNTRUSTED_CONTENT_NOTE}\n\n${text}`
173
+ : text,
174
+ },
175
+ ],
176
+ structuredContent: {
177
+ result,
178
+ ...provenanceFields(tool.untrustedContent),
179
+ },
180
+ };
181
+ }
182
+ function asErrorResult(error) {
183
+ return {
184
+ isError: true,
185
+ content: [
186
+ {
187
+ type: "text",
188
+ text: formatError(error),
189
+ },
190
+ ],
191
+ };
192
+ }
193
+ function operationDescription(operation) {
194
+ const lines = [
195
+ operation.description,
196
+ "",
197
+ `Luca operation: ${operation.method} ${operation.path}`,
198
+ `Required scopes: ${operation.scopes.join(", ")}`,
199
+ `Required capability: ${operation.requiredScope.capability}; data sensitivity: ${operation.requiredScope.dataSensitivity}`,
200
+ operation.idempotencyRequired
201
+ ? "Requires Idempotency-Key. Provide idempotencyKey for safe retries, or the server will generate one."
202
+ : "Does not require idempotency.",
203
+ ];
204
+ if (operation.confirmRequired) {
205
+ lines.push("Destructive: has a real-world side effect. You must pass confirm: true; the call is rejected without it.");
206
+ }
207
+ lines.push("Use workspaceId or workspaceSlug to override the default workspace configured by environment.");
208
+ return lines.join("\n");
209
+ }
210
+ function confirmationError(toolName) {
211
+ logAgentSurfaceEvent("mcp.write_gate.rejected", { toolName });
212
+ return {
213
+ isError: true,
214
+ content: [
215
+ {
216
+ type: "text",
217
+ text: `${toolName}: This tool requires confirm: true because it has a real-world side effect (sends messages to leads or re-triggers downstream automations). Re-call the tool with confirm: true to proceed.`,
218
+ },
219
+ ],
220
+ structuredContent: {
221
+ error: {
222
+ code: "confirmation_required",
223
+ toolName,
224
+ requiredArgument: "confirm",
225
+ },
226
+ },
227
+ };
228
+ }