@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.
- package/CHANGELOG.md +316 -0
- package/LICENSE +21 -0
- package/README.md +19 -288
- package/dist/annotations.js +33 -18
- package/dist/client-address.js +64 -0
- package/dist/completions.js +24 -27
- package/dist/config.js +89 -38
- package/dist/errors.js +94 -13
- package/dist/fields.js +68 -0
- package/dist/fixed-window.js +46 -0
- package/dist/generated/openapi-inputs.js +216 -5820
- package/dist/generated/openapi-outputs.js +19014 -14001
- package/dist/generated/route-catalog.js +80 -0
- package/dist/http.js +103 -41
- package/dist/index.js +4 -1
- package/dist/oauth-challenge.js +74 -0
- package/dist/oauth-resolver.js +58 -61
- package/dist/observability.js +30 -15
- package/dist/openapi-schema.js +175 -0
- package/dist/operations/catalog.js +17 -0
- package/dist/operations/groups/analytics.js +20 -10
- package/dist/operations/groups/bookings.js +43 -15
- package/dist/operations/groups/broadcasts.js +29 -58
- package/dist/operations/groups/cadences.js +3 -1
- package/dist/operations/groups/callEvents.js +19 -72
- package/dist/operations/groups/campaigns.js +28 -32
- package/dist/operations/groups/channels.js +23 -0
- package/dist/operations/groups/conversations.js +14 -25
- package/dist/operations/groups/insights.js +4 -0
- package/dist/operations/groups/integrations.js +15 -17
- package/dist/operations/groups/knowledge.js +5 -1
- package/dist/operations/groups/leads.js +22 -23
- package/dist/operations/groups/learning.js +13 -1
- package/dist/operations/groups/memory.js +4 -6
- package/dist/operations/groups/reports.js +1 -1
- package/dist/operations/groups/reviewQueue.js +17 -14
- package/dist/operations/groups/safety.js +1 -1
- package/dist/operations/groups/voice.js +0 -1
- package/dist/operations/groups/webhooks.js +15 -3
- package/dist/operations/manifest.js +7 -6
- package/dist/operations/registry.js +102 -108
- package/dist/operations.js +4 -3
- package/dist/optional-field.js +15 -3
- package/dist/page-contract.js +74 -0
- package/dist/pagination.js +147 -67
- package/dist/prompts.js +122 -97
- package/dist/rate-limit.js +40 -0
- package/dist/remote.js +145 -65
- package/dist/resilience.js +107 -52
- package/dist/resolvers.js +125 -0
- package/dist/scopes.js +70 -17
- package/dist/serialization.js +32 -22
- package/dist/server.js +148 -132
- package/dist/standard-schema.js +215 -0
- package/dist/task-tools/analytics.js +145 -86
- package/dist/task-tools/bookings.js +168 -81
- package/dist/task-tools/cadences.js +30 -45
- package/dist/task-tools/calls.js +131 -124
- package/dist/task-tools/inbox.js +37 -47
- package/dist/task-tools/leads.js +49 -77
- package/dist/task-tools/runtime.js +75 -15
- package/dist/token-verification.js +42 -0
- package/dist/tool-cache.js +3 -2
- package/dist/tool.js +151 -96
- package/dist/version.js +1 -1
- package/package.json +37 -16
- package/dist/openapi-zod.js +0 -335
- package/dist/page-shape.js +0 -62
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
17
|
-
paginates: operation.
|
|
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
|
|
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
|
|
2
|
-
import
|
|
3
|
-
import
|
|
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 {
|
|
6
|
-
import { optionalField } from "../optional-field.js";
|
|
7
|
-
import {
|
|
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
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
131
|
-
const OPENAPI_OUTPUTS_BY_ROUTE =
|
|
132
|
-
|
|
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:
|
|
145
|
-
.
|
|
146
|
-
|
|
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:
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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,
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
280
|
-
* can come back blanked
|
|
281
|
-
*
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
const
|
|
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:
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
paginates:
|
|
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",
|
|
317
|
+
...optionalField("redactedFields", OPENAPI_REDACTED_FIELDS_BY_ROUTE[`${method} ${path}`]),
|
|
324
318
|
}),
|
|
325
319
|
buildRequest: (toolInput) => ({
|
|
326
320
|
pathParams: pathParams(toolInput, path),
|
package/dist/operations.js
CHANGED
|
@@ -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
|
|
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 {
|
|
12
|
+
export { DEFAULT_MAX_PAGES, needsConfirmation, outputSchemaFor, } from "./operations/registry.js";
|
package/dist/optional-field.js
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as Option from "effect/Option";
|
|
2
|
+
import * as R from "effect/Record";
|
|
2
3
|
export function optionalField(key, value) {
|
|
3
|
-
return Option.
|
|
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) =>
|
|
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
|
+
}
|