@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.
- package/README.md +110 -42
- package/dist/annotations.js +45 -0
- package/dist/completions.js +90 -0
- package/dist/config.js +55 -29
- package/dist/errors.js +59 -14
- package/dist/generated/openapi-inputs.js +4324 -1429
- package/dist/generated/openapi-outputs.js +21992 -8516
- package/dist/generated/route-catalog.js +1480 -0
- package/dist/http.js +95 -103
- package/dist/index.js +16 -10
- package/dist/oauth-resolver.js +58 -36
- package/dist/observability.js +11 -3
- package/dist/openapi-zod.js +129 -65
- package/dist/operations/catalog.js +50 -0
- package/dist/operations/groups/analytics.js +122 -0
- package/dist/operations/groups/bookings.js +189 -0
- package/dist/operations/groups/broadcasts.js +106 -0
- package/dist/operations/groups/cadences.js +14 -0
- package/dist/operations/groups/callEvents.js +131 -0
- package/dist/operations/groups/campaigns.js +132 -0
- package/dist/operations/groups/capabilities.js +8 -0
- package/dist/operations/groups/coach.js +43 -0
- package/dist/operations/groups/conversations.js +48 -0
- package/dist/operations/groups/insights.js +33 -0
- package/dist/operations/groups/integrations.js +80 -0
- package/dist/operations/groups/knowledge.js +34 -0
- package/dist/operations/groups/leads.js +101 -0
- package/dist/operations/groups/learning.js +24 -0
- package/dist/operations/groups/memory.js +27 -0
- package/dist/operations/groups/reports.js +14 -0
- package/dist/operations/groups/reviewQueue.js +57 -0
- package/dist/operations/groups/safety.js +8 -0
- package/dist/operations/groups/usage.js +13 -0
- package/dist/operations/groups/voice.js +14 -0
- package/dist/operations/groups/webhooks.js +76 -0
- package/dist/operations/manifest.js +40 -0
- package/dist/operations/registry.js +331 -0
- package/dist/operations.js +11 -780
- package/dist/optional-field.js +12 -0
- package/dist/page-shape.js +47 -0
- package/dist/pagination.js +164 -0
- package/dist/prompts.js +63 -8
- package/dist/provenance.js +18 -2
- package/dist/remote.js +80 -49
- package/dist/resilience.js +117 -0
- package/dist/serialization.js +36 -0
- package/dist/server.js +256 -281
- package/dist/task-tools/analytics.js +168 -0
- package/dist/task-tools/bookings.js +154 -0
- package/dist/task-tools/cadences.js +65 -0
- package/dist/task-tools/calls.js +202 -0
- package/dist/task-tools/inbox.js +74 -0
- package/dist/task-tools/leads.js +123 -0
- package/dist/task-tools/runtime.js +92 -0
- package/dist/task-tools.js +21 -0
- package/dist/tool-cache.js +39 -0
- package/dist/tool.js +228 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/version.js +14 -0
- 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
|
+
}
|