@setluca/mcp 0.2.0 → 0.2.1

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/dist/server.js CHANGED
@@ -8,9 +8,9 @@ import { LucaApi, LucaApiLive } from "./http.js";
8
8
  import { logAgentSurfaceEvent } from "./observability.js";
9
9
  import { LUCA_OPERATIONS, operationGroups, operationManifest, } from "./operations.js";
10
10
  import { optionalField } from "./optional-field.js";
11
- import { LUCA_PROMPTS, } from "./prompts.js";
11
+ import { LUCA_API_PLANNER_PROMPT, LUCA_PROMPTS, } from "./prompts.js";
12
12
  import { provenanceFields } from "./provenance.js";
13
- import { LUCA_RESOURCES } from "./resources.js";
13
+ import { LUCA_OPERATION_MANIFEST_RESOURCE, LUCA_RESOURCES, } from "./resources.js";
14
14
  import { toJsonText } from "./serialization.js";
15
15
  import { LUCA_TASK_TOOLS } from "./task-tools.js";
16
16
  import { fromOperation, fromTaskTool, operationProgram, registerTool, } from "./tool.js";
@@ -48,9 +48,9 @@ export const LUCA_SERVER_INFO = {
48
48
  *
49
49
  * `private` throughout: the tool surface varies with the `?toolset=` query
50
50
  * param, so a shared cache keyed on anything less than the whole request
51
- * could answer one shape with another. `resources/read` is deliberately
52
- * absent — it returns live coach data and keeps the conservative default of
53
- * no caching at all.
51
+ * could answer one shape with another. `resources/read` is left out on purpose.
52
+ * It returns live coach data and keeps the conservative default of no caching
53
+ * at all.
54
54
  *
55
55
  * These hints reach a client on the remote Streamable HTTP transport only.
56
56
  * The SDK installs the modern-revision handlers from its HTTP entry
@@ -58,8 +58,8 @@ export const LUCA_SERVER_INFO = {
58
58
  * constructed here and connected straight to a stdio or in-memory transport
59
59
  * negotiates the legacy `initialize` handshake instead, and the legacy wire
60
60
  * codec stamps no cache fields. Passing the table is still correct on both
61
- * paths — it is inert on stdio rather than wrong — but only the remote
62
- * handler can be asserted against, which is where the coverage lives.
61
+ * paths, inert on stdio rather than wrong, but only the remote handler can be
62
+ * asserted against, which is where the coverage lives.
63
63
  */
64
64
  const STATIC_LIST_TTL_MS = 3_600_000;
65
65
  const LUCA_CACHE_HINTS = {
@@ -79,8 +79,8 @@ export function parseToolset(value) {
79
79
  }
80
80
  const OPERATIONS_BY_ID = new Map(
81
81
  // This map is built once at module load, so a mutant inside it never takes
82
- // effect under a test runner that caches the module — it is unkillable by
83
- // construction rather than untested; the lookups below are covered.
82
+ // effect under a test runner that caches the module. It is unkillable by
83
+ // construction rather than untested, and the lookups below are covered.
84
84
  // Stryker disable next-line ArrowFunction: module-level initializer, never re-evaluated per test
85
85
  LUCA_OPERATIONS.map((operation) => [operation.id, operation]));
86
86
  function operationByIdOrThrow(id) {
@@ -88,7 +88,7 @@ function operationByIdOrThrow(id) {
88
88
  // the same CI-validated manifest, so this throw is unreachable with real data
89
89
  // and the message text is never observed.
90
90
  return Option.fromNullable(OPERATIONS_BY_ID.get(id)).pipe(Option.getOrThrowWith(
91
- // Stryker disable next-line all: unreachable defensive throw — every composed id exists in the manifest
91
+ // Stryker disable next-line all: unreachable defensive throw, every composed id exists in the manifest
92
92
  () => new Error(`task tool composes unknown operation: ${id}`)));
93
93
  }
94
94
  function registerLucaResources(server, lucaLayer, completions) {
@@ -114,15 +114,15 @@ function resourceOperationOrThrow(operationId) {
114
114
  // CI-validated manifest, so this throw is unreachable with real data and the
115
115
  // message text is never observed.
116
116
  Option.getOrThrowWith(
117
- // Stryker disable next-line all: unreachable defensive throw — every resource operation id exists in the manifest
117
+ // Stryker disable next-line all: unreachable defensive throw, every resource operation id exists in the manifest
118
118
  () => new Error(`Unknown resource operation: ${operationId}`)));
119
119
  }
120
120
  function registerTemplatedResource(server, resource, operation, lucaLayer, metadata, completions) {
121
121
  return Option.fromNullable(resource.uriTemplate).pipe(Option.match({
122
122
  onNone: () => undefined,
123
123
  onSome: (template) => server.registerResource(resource.name,
124
- // These templates are not enumerable — a lead or conversation uri is
125
- // addressable but not listable — which an explicit `list: undefined`
124
+ // These templates are not enumerable. A lead or conversation uri is
125
+ // addressable but not listable, which an explicit `list: undefined`
126
126
  // states at the call site. Omitting the key entirely reads the same
127
127
  // to the SDK, so a mutant that drops it changes nothing observable.
128
128
  // Stryker disable next-line ObjectLiteral: an absent `list` and an undefined one are the same to the SDK
@@ -158,14 +158,16 @@ function readResult(operation, uri, result) {
158
158
  contents: [resourceContent(uri, resourcePayload(operation, result))],
159
159
  };
160
160
  }
161
- /** The operation input a resource read runs, recorded as it is built. */
161
+ /**
162
+ * The operation input a resource read runs, recorded as it is built.
163
+ *
164
+ * Only the resource's name is logged. Every variable a template carries is an
165
+ * id the client already typed, and a resource read takes no cursor: each one
166
+ * answers with a whole record or a single page.
167
+ */
162
168
  function resourceReadInput(resource, variables) {
163
- const input = resource.buildInput(variables);
164
- logAgentSurfaceEvent("mcp.resources.read", {
165
- resourceType: resource.name,
166
- hasCursor: input.cursor !== undefined,
167
- });
168
- return input;
169
+ logAgentSurfaceEvent("mcp.resources.read", { resourceType: resource.name });
170
+ return resource.buildInput(variables);
169
171
  }
170
172
  /**
171
173
  * The payload of a successful read, provenance-tagged when it carries text an
@@ -185,9 +187,9 @@ function resourceContent(uri, payload) {
185
187
  };
186
188
  }
187
189
  function registerOperationManifestResource(server) {
188
- server.registerResource("luca-public-operations", "luca://operations", {
189
- title: "Luca Public API Operations",
190
- description: "MCP operation manifest derived from Luca's public API key policy.",
190
+ server.registerResource(LUCA_OPERATION_MANIFEST_RESOURCE.name, LUCA_OPERATION_MANIFEST_RESOURCE.uri, {
191
+ title: LUCA_OPERATION_MANIFEST_RESOURCE.title,
192
+ description: LUCA_OPERATION_MANIFEST_RESOURCE.description,
191
193
  mimeType: "application/json",
192
194
  }, async (uri) => ({
193
195
  contents: [
@@ -211,14 +213,11 @@ function registerOperationManifestResource(server) {
211
213
  * Two details of the SDK shape this. It looks for the completion metadata on
212
214
  * the schema *inside* the optional wrapper, not on the wrapper. And
213
215
  * `completable` stamps that metadata with a non-configurable property, so a
214
- * schema can be stamped once and never again — while `LUCA_PROMPTS` is a
216
+ * schema can be stamped once and never again, while `LUCA_PROMPTS` is a
215
217
  * module singleton the Worker re-registers on every request. Cloning the inner
216
218
  * schema first gives each build its own object to stamp.
217
219
  */
218
220
  function completableArgs(schema, completions) {
219
- if (!completions) {
220
- return schema;
221
- }
222
221
  return Object.fromEntries(Object.entries(schema).map(([name, field]) => [
223
222
  name,
224
223
  isCompletionVariable(name)
@@ -241,26 +240,33 @@ function promptArgsSchema(prompt, completions) {
241
240
  return z.object(completableArgs(prompt.argsSchema, completions));
242
241
  }
243
242
  /**
244
- * Registers the static planner prompt plus every prompt in `prompts`.
243
+ * Registers the planner prompt plus every prompt in `prompts`.
245
244
  *
246
245
  * The list is a parameter so a test can drive this with its own prompt instead
247
246
  * of mutating the exported `LUCA_PROMPTS` array out from under other tests.
248
247
  */
249
- export function registerPrompts(server, prompts = LUCA_PROMPTS, completions) {
250
- server.registerPrompt("luca-api-planner", {
251
- title: "Luca API Planner",
252
- description: "Plan a Luca API workflow using the MCP tools, scopes, and idempotency rules.",
253
- argsSchema: z.object({
254
- task: z
255
- .string()
256
- .optional()
257
- .describe("The Luca API workflow the user wants to perform."),
258
- }),
259
- }, (args) => {
260
- const { task } = args;
248
+ export function registerPrompts(server, prompts, completions) {
249
+ for (const prompt of [LUCA_API_PLANNER_PROMPT, ...prompts]) {
250
+ registerPrompt(server, prompt, completions);
251
+ }
252
+ }
253
+ function registerPrompt(server, prompt, completions) {
254
+ const config = {
255
+ title: prompt.title,
256
+ description: prompt.description,
257
+ ...optionalField("argsSchema", promptArgsSchema(prompt, completions)),
258
+ };
259
+ server.registerPrompt(prompt.name, config, (args) => {
260
+ // Parse with the prompt's own argsSchema, not a shared guess at one. A
261
+ // schema that does not match what the client was told to send silently
262
+ // drops any argument outside it before `prompt.build` ever sees it.
263
+ const parsedArgs = z.object(prompt.argsSchema).parse(args ?? {});
261
264
  logAgentSurfaceEvent("mcp.prompts.get", {
262
- prompt: "luca-api-planner",
263
- hasGuidance: false,
265
+ prompt: prompt.name,
266
+ // Booleans and the arg name only, never guidance text: `guidance` only
267
+ // exists on one prompt's schema, so this reads it without assuming
268
+ // every prompt shares that field.
269
+ hasGuidance: Boolean(parsedArgs.guidance),
264
270
  });
265
271
  return {
266
272
  messages: [
@@ -268,58 +274,19 @@ export function registerPrompts(server, prompts = LUCA_PROMPTS, completions) {
268
274
  role: "user",
269
275
  content: {
270
276
  type: "text",
271
- text: [
272
- "Use Luca MCP tools only for public API operations.",
273
- "Do not ask for database access, internal endpoints, webhook ingress secrets, or browser cookies.",
274
- "For writes, pass a stable idempotencyKey when retrying the same user intent.",
275
- "Check luca://operations when you need exact tool names, scopes, and paths.",
276
- Option.fromNullable(task).pipe(Option.filter((value) => value.length > 0), Option.map((value) => `User task: ${value}`), Option.getOrUndefined),
277
- ]
278
- .filter(Boolean)
279
- .join("\n"),
277
+ text: prompt.build(parsedArgs),
280
278
  },
281
279
  },
282
280
  ],
283
281
  };
284
282
  });
285
- for (const prompt of prompts) {
286
- const config = {
287
- title: prompt.title,
288
- description: prompt.description,
289
- ...optionalField("argsSchema", promptArgsSchema(prompt, completions)),
290
- };
291
- server.registerPrompt(prompt.name, config, (args) => {
292
- // Parse with the prompt's own argsSchema, not a shared guess at one — a
293
- // schema that does not match what the client was told to send silently
294
- // drops any argument outside it before `prompt.build` ever sees it.
295
- const parsedArgs = z.object(prompt.argsSchema).parse(args ?? {});
296
- logAgentSurfaceEvent("mcp.prompts.get", {
297
- prompt: prompt.name,
298
- // Booleans and the arg name only, never guidance text: `guidance` only
299
- // exists on one prompt's schema, so this reads it without assuming
300
- // every prompt shares that field.
301
- hasGuidance: Boolean(parsedArgs.guidance),
302
- });
303
- return {
304
- messages: [
305
- {
306
- role: "user",
307
- content: {
308
- type: "text",
309
- text: prompt.build(parsedArgs),
310
- },
311
- },
312
- ],
313
- };
314
- });
315
- }
316
283
  }
317
284
  /**
318
285
  * Build the Luca MCP server: registers the operation tools, the task tools, the
319
286
  * `luca://…` resources, and the planner prompt against a shared `LucaApi` layer.
320
287
  * `options.lucaLayer` swaps the HTTP client (tests inject an in-memory handler);
321
- * `options.toolset` trims the surface — `"tasks"` exposes only the task tools
322
- * plus capability discovery, `"full"` (default) exposes everything.
288
+ * `options.toolset` picks the tool set. `"tasks"` exposes only the task tools
289
+ * plus capability discovery, and `"full"`, the default, exposes everything.
323
290
  */
324
291
  export function createLucaServer(options = {}) {
325
292
  const lucaLayer = options.lucaLayer ?? LucaLive;
@@ -331,8 +298,8 @@ export function createLucaServer(options = {}) {
331
298
  const server = new McpServer(LUCA_SERVER_INFO, {
332
299
  cacheHints: LUCA_CACHE_HINTS,
333
300
  });
334
- // The tasks surface carries one operation — capability discovery — so a client
335
- // can still learn what the workspace supports without the full tool list.
301
+ // The tasks set carries one operation, capability discovery, so a client can
302
+ // still learn what the workspace supports without the full tool list.
336
303
  const operations = toolset === "tasks"
337
304
  ? [operationByIdOrThrow("capabilities.get")]
338
305
  : LUCA_OPERATIONS;
@@ -30,7 +30,7 @@ const getAnalytics = {
30
30
  }));
31
31
  // broadcasts.list doesn't depend on the per-campaign analytics loop, so
32
32
  // it runs alongside it instead of after it. Effect.all defaults to
33
- // concurrency 1 (sequential) — without `concurrency: "unbounded"` here
33
+ // concurrency 1 (sequential), so without `concurrency: "unbounded"` here
34
34
  // the broadcasts call would still wait on the whole loop below.
35
35
  const [perCampaign, broadcasts] = yield* Effect.all([
36
36
  Effect.forEach(listingIds(campaignPage, campaignLimit), (id) => api
@@ -11,7 +11,7 @@ const AVAILABILITY_WINDOW_MS = 24 * 60 * 60 * 1000;
11
11
  * `Option.none()` means "we could not tell", which is different from "nothing
12
12
  * is open". The availability route answers with `source: "fallback"` or
13
13
  * `"provider_error"` when it could not reach the provider, and it defaults to
14
- * Calendly when the caller names no provider — so a coach on Cal.com asking
14
+ * Calendly when the caller names no provider, so a coach on Cal.com asking
15
15
  * without one gets a confident-looking answer about the wrong calendar. Only a
16
16
  * `live_provider` read is worth blocking a booking over.
17
17
  */
@@ -40,11 +40,14 @@ const AvailabilityPageSchema = z.object({
40
40
  source: z.string(),
41
41
  slots: z.array(z.object({ startsAt: z.string() })),
42
42
  });
43
- /** Whether two ISO-8601 instants name the same moment, whatever their offset. */
43
+ /**
44
+ * Whether two ISO-8601 instants name the same moment, whatever their offset.
45
+ *
46
+ * An unparseable instant is `NaN`, which is not equal to itself, so a garbled
47
+ * slot on either side answers false without a guard of its own.
48
+ */
44
49
  function sameInstant(left, right) {
45
- const a = new Date(left).getTime();
46
- const b = new Date(right).getTime();
47
- return !(Number.isNaN(a) || Number.isNaN(b)) && a === b;
50
+ return new Date(left).getTime() === new Date(right).getTime();
48
51
  }
49
52
  const bookCall = {
50
53
  name: "luca_book_call",
@@ -24,7 +24,7 @@ const postCallQueue = {
24
24
  /**
25
25
  * The id of the call event attached to a booking, if the API has one. A booking
26
26
  * whose call has not happened yet answers 404, which is the normal case rather
27
- * than a failure — so is any other read error here, because the outcome write
27
+ * than a failure. So is any other read error here, because the outcome write
28
28
  * below is worth doing on its own.
29
29
  */
30
30
  const CallEventId = Schema.Struct({
@@ -153,7 +153,7 @@ const closeCallLoop = {
153
153
  const feedback = yield* Option.match(decodeCallEventId(page), {
154
154
  onNone: () =>
155
155
  // No call event means attendance has nowhere to go. The revenue write
156
- // below still runs — that is the half the coach is usually after.
156
+ // below still runs, and that is the half the coach is usually after.
157
157
  Effect.succeed({
158
158
  reported: false,
159
159
  reason: "no_call_event_for_booking",
@@ -1,13 +1,63 @@
1
1
  import { Effect, Record } from "effect";
2
2
  import * as z from "zod/v4";
3
3
  import { formatError } from "../errors.js";
4
+ import { logAgentSurfaceEvent } from "../observability.js";
4
5
  import { WORKSPACE_INPUT_FIELDS } from "../operations.js";
5
6
  import { listingIds, numberInput, taskRequest, } from "./runtime.js";
6
7
  const DEFAULT_QUEUE_LIMIT = 20;
7
8
  const DEFAULT_EXPLAIN_TOP = 3;
9
+ const TRIAGE_INBOX_TOOL_NAME = "luca_triage_inbox";
10
+ /**
11
+ * One explanation, with a failed lookup carried in the result instead of
12
+ * cancelling the others. A queue the coach can read with two explanations
13
+ * missing beats no queue at all.
14
+ */
15
+ function explainOne(api, operation, input, id) {
16
+ return api
17
+ .request(taskRequest(operation, input, { pathParams: { id } }))
18
+ .pipe(Effect.map((explanation) => ({
19
+ id,
20
+ explanation,
21
+ failed: false,
22
+ })), Effect.catchAll((error) =>
23
+ // Keep the structured formatter, not a lossy String(error): a
24
+ // LucaHttpError's status/requestId survive for debugging a partial
25
+ // failure inside the composed tool.
26
+ Effect.succeed({
27
+ id,
28
+ explanation: { explainError: formatError(error) },
29
+ failed: true,
30
+ })));
31
+ }
32
+ /**
33
+ * How the explain fan-out went, as counts.
34
+ *
35
+ * `mcp.tool.called` reports this tool as a success even when every explanation
36
+ * inside it failed, because a failed lookup comes back as an `explainError`
37
+ * field rather than an error result. Without this line, an explain route that
38
+ * is broken for every caller looks identical in logs to one that works.
39
+ *
40
+ * The single-route explain tools need no line of their own: `mcp.tool.called`
41
+ * already carries their name and outcome. This fan-out is the only place where
42
+ * a failure has nowhere else to show up.
43
+ *
44
+ * Counts only, never an id or an explanation. Both are lead-derived.
45
+ */
46
+ function logExplainFanOut(attempts) {
47
+ if (attempts.length === 0) {
48
+ return;
49
+ }
50
+ const failed = attempts.filter((attempt) => attempt.failed).length;
51
+ logAgentSurfaceEvent("mcp.explain", {
52
+ toolName: TRIAGE_INBOX_TOOL_NAME,
53
+ requested: attempts.length,
54
+ explained: attempts.length - failed,
55
+ failed,
56
+ });
57
+ }
8
58
  /** The review session's entry point: the queue, with the top items explained. */
9
59
  const triageInbox = {
10
- name: "luca_triage_inbox",
60
+ name: TRIAGE_INBOX_TOOL_NAME,
11
61
  title: "Triage the review queue",
12
62
  description: "Return the coach's prioritized reply queue: pending drafts with lead context and, for the top items, why Luca drafted what it drafted. Start every review session here, then act on an item with luca_approve_and_send, luca_draft_reply, or luca_reviewQueue_reject. Example: call with { explainTop: 3 } to get the queue plus explanations for the three most urgent drafts.",
13
63
  inputSchema: {
@@ -35,24 +85,20 @@ const triageInbox = {
35
85
  query: { limit: input.limit ?? DEFAULT_QUEUE_LIMIT },
36
86
  }));
37
87
  const ids = listingIds(queue, numberInput(input.explainTop, DEFAULT_EXPLAIN_TOP));
38
- const explained = yield* Effect.forEach(ids, (id) => api
39
- .request(taskRequest(operationById("reviewQueue.explain"), input, {
40
- pathParams: { id },
41
- }))
42
- .pipe(Effect.catchAll((error) =>
43
- // Keep the structured formatter, not a lossy String(error): a
44
- // LucaHttpError's status/requestId survive for debugging a
45
- // partial failure inside the composed tool.
46
- Effect.succeed({ explainError: formatError(error) })), Effect.map((explanation) => [
47
- id,
48
- explanation,
49
- ])),
88
+ const explain = operationById("reviewQueue.explain");
89
+ const attempts = yield* Effect.forEach(ids, (id) => explainOne(api, explain, input, id),
50
90
  // Effect.forEach defaults to concurrency 1 (sequential). `explainTop`
51
- // caps this fan-out at 5 independent lookups, so unbounded is fine —
52
- // there's no unbounded caller input that could turn this into a
53
- // thundering herd.
91
+ // caps this fan-out at 5 independent lookups, so unbounded is fine.
92
+ // No caller input can grow it into a thundering herd.
54
93
  { concurrency: "unbounded" });
55
- return { queue, explanations: Record.fromEntries(explained) };
94
+ logExplainFanOut(attempts);
95
+ return {
96
+ queue,
97
+ explanations: Record.fromEntries(attempts.map((attempt) => [
98
+ attempt.id,
99
+ attempt.explanation,
100
+ ])),
101
+ };
56
102
  }),
57
103
  };
58
104
  /** The day's opening read: the last 24h of queue activity, already counted. */
@@ -18,7 +18,7 @@ function workspaceOverride(input) {
18
18
  * Assemble the request a task tool sends: the resolved operation plus its
19
19
  * path/query/body, with the workspace override and idempotency key folded in
20
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
21
+ * `commonInput`. One seam owns "how a task request is built", so a tool body
22
22
  * reads as the fields it maps, not the plumbing. Pass `body: undefined` to omit
23
23
  * the body entirely (an absent optional field, never `{ body: undefined }`).
24
24
  */
@@ -35,8 +35,8 @@ export function taskRequest(operation, input, parts = {}) {
35
35
  /**
36
36
  * The only part of a listing page a composing tool reads for itself: the ids of
37
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.
38
+ * intact for the caller, returned exactly as the API sent it, while the ids the
39
+ * tool acts on are validated rather than asserted.
40
40
  */
41
41
  const ListingIds = Schema.Struct({
42
42
  items: Schema.optional(Schema.Array(Schema.Struct({ id: Schema.optional(Schema.String) }))),
@@ -44,7 +44,7 @@ const ListingIds = Schema.Struct({
44
44
  const decodeListingIds = Schema.decodeUnknownOption(ListingIds);
45
45
  /**
46
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
47
+ * cannot read, or whose items carry no id, contributes nothing. The tool still
48
48
  * returns the page itself rather than failing over a fan-out it cannot do.
49
49
  */
50
50
  export function listingIds(page, limit) {
@@ -66,7 +66,7 @@ export function listingIds(page, limit) {
66
66
  }
67
67
  /**
68
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
69
+ * is absent or blank. Sending an empty string would mean something different to
70
70
  * the API than leaving the field off.
71
71
  */
72
72
  export function textBody(key, value) {
@@ -1,7 +1,7 @@
1
1
  // The task-tool registry: composed, intent-level entry points layered over the
2
2
  // generated 1:1 operation tools. It is deliberately NOT `LUCA_OPERATIONS`,
3
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
4
+ // routes, and a task tool has no route of its own. Each tool hand-writes its
5
5
  // input fields and composes existing operations through `LucaApi.request`, and
6
6
  // they are split one file per domain under `task-tools/`. The shape they all
7
7
  // share, and the request plumbing they all use, lives in `task-tools/runtime.ts`.
package/dist/tool.js CHANGED
@@ -105,10 +105,10 @@ function ended(outcome, result) {
105
105
  return { outcome, result };
106
106
  }
107
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.
108
+ * A tool call's result, confirmation gate included. Every way the call can end
109
+ * leaves as a tool result, so nothing reaches the transport as an unhandled
110
+ * rejection. That covers unreadable arguments, a rejected confirmation, a
111
+ * failed request, and a defect thrown while the request was being built.
112
112
  *
113
113
  * The log line sits after the defect handler, so a call produces exactly one
114
114
  * line whichever way it ended.
@@ -156,8 +156,8 @@ export function operationProgram(operation, toolInput) {
156
156
  }
157
157
  /**
158
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
159
+ * flagged untrusted on both halves, the structured `provenance.untrusted`
160
+ * field that a lead cannot spoof and a human-facing preamble on the text, so a
161
161
  * downstream agent treats embedded text as data, not instructions. What the
162
162
  * structured half attaches is `provenanceFields`, shared with the resource
163
163
  * reads in `server.ts`.