@setluca/mcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/LICENSE +21 -0
  3. package/README.md +19 -258
  4. package/dist/annotations.js +35 -20
  5. package/dist/client-address.js +64 -0
  6. package/dist/completions.js +44 -27
  7. package/dist/config.js +89 -38
  8. package/dist/errors.js +99 -22
  9. package/dist/fields.js +68 -0
  10. package/dist/fixed-window.js +46 -0
  11. package/dist/generated/openapi-inputs.js +216 -5820
  12. package/dist/generated/openapi-outputs.js +19014 -14001
  13. package/dist/generated/route-catalog.js +80 -0
  14. package/dist/http.js +108 -44
  15. package/dist/index.js +4 -1
  16. package/dist/oauth-challenge.js +74 -0
  17. package/dist/oauth-resolver.js +58 -61
  18. package/dist/observability.js +31 -16
  19. package/dist/openapi-schema.js +175 -0
  20. package/dist/operations/catalog.js +17 -0
  21. package/dist/operations/groups/analytics.js +20 -10
  22. package/dist/operations/groups/bookings.js +43 -15
  23. package/dist/operations/groups/broadcasts.js +29 -58
  24. package/dist/operations/groups/cadences.js +3 -1
  25. package/dist/operations/groups/callEvents.js +19 -72
  26. package/dist/operations/groups/campaigns.js +28 -32
  27. package/dist/operations/groups/channels.js +23 -0
  28. package/dist/operations/groups/conversations.js +14 -25
  29. package/dist/operations/groups/insights.js +4 -0
  30. package/dist/operations/groups/integrations.js +15 -17
  31. package/dist/operations/groups/knowledge.js +5 -1
  32. package/dist/operations/groups/leads.js +22 -23
  33. package/dist/operations/groups/learning.js +13 -1
  34. package/dist/operations/groups/memory.js +4 -6
  35. package/dist/operations/groups/reports.js +1 -1
  36. package/dist/operations/groups/reviewQueue.js +17 -14
  37. package/dist/operations/groups/safety.js +1 -1
  38. package/dist/operations/groups/voice.js +0 -1
  39. package/dist/operations/groups/webhooks.js +16 -4
  40. package/dist/operations/manifest.js +9 -8
  41. package/dist/operations/registry.js +110 -116
  42. package/dist/operations.js +6 -5
  43. package/dist/optional-field.js +15 -3
  44. package/dist/page-contract.js +74 -0
  45. package/dist/pagination.js +149 -68
  46. package/dist/prompts.js +139 -88
  47. package/dist/provenance.js +2 -2
  48. package/dist/rate-limit.js +40 -0
  49. package/dist/remote.js +145 -65
  50. package/dist/resilience.js +120 -55
  51. package/dist/resolvers.js +125 -0
  52. package/dist/resources.js +17 -2
  53. package/dist/scopes.js +207 -0
  54. package/dist/serialization.js +32 -22
  55. package/dist/server.js +174 -191
  56. package/dist/standard-schema.js +215 -0
  57. package/dist/task-tools/analytics.js +145 -86
  58. package/dist/task-tools/bookings.js +176 -86
  59. package/dist/task-tools/cadences.js +30 -45
  60. package/dist/task-tools/calls.js +132 -125
  61. package/dist/task-tools/inbox.js +89 -53
  62. package/dist/task-tools/leads.js +49 -77
  63. package/dist/task-tools/runtime.js +80 -20
  64. package/dist/task-tools.js +1 -1
  65. package/dist/token-verification.js +42 -0
  66. package/dist/tool-cache.js +3 -2
  67. package/dist/tool.js +155 -100
  68. package/dist/version.js +1 -1
  69. package/package.json +40 -18
  70. package/dist/openapi-zod.js +0 -333
  71. package/dist/page-shape.js +0 -47
  72. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -1,120 +1,92 @@
1
- import * as z from "zod/v4";
2
- import { confirmInput, WORKSPACE_INPUT_FIELDS } from "../operations.js";
1
+ import * as Schema from "effect/Schema";
2
+ import { MessagingChannel, NonEmptyString, WORKSPACE_INPUT_FIELDS, } from "../fields.js";
3
3
  import { optionalField } from "../optional-field.js";
4
- import { numberInput, stringInput, taskRequest, textBody, } from "./runtime.js";
5
- const findLeads = {
4
+ import { idempotencyKeyInput, numberInput, operationResult, stringInput, taskTool, textBody, } from "./runtime.js";
5
+ const findLeads = taskTool({
6
6
  name: "luca_find_leads",
7
7
  title: "Find leads",
8
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
9
  inputSchema: {
10
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)."),
11
+ query: NonEmptyString.annotate({
12
+ description: "Free-text search: name, handle, or keyword.",
13
+ }),
14
+ channel: Schema.optionalKey(MessagingChannel.annotate({ description: "Optional channel filter." })),
15
+ limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 50 })).annotate({
16
+ description: "Max results (default 10).",
17
+ })),
26
18
  },
27
- readOnly: true,
28
- untrustedContent: true,
19
+ outputSchema: operationResult("leads.list"),
29
20
  composes: ["leads.list"],
30
- run: (api, operationById, input) => api.request(taskRequest(operationById("leads.list"), input, {
21
+ run: (call, input) => call("leads.list", {
31
22
  query: {
32
23
  q: stringInput(input.query),
33
24
  ...optionalField("channel", input.channel),
34
25
  limit: numberInput(input.limit, 10),
35
26
  },
36
- })),
37
- };
38
- const flagForHuman = {
27
+ }),
28
+ });
29
+ const flagForHuman = taskTool({
39
30
  name: "luca_flag_for_human",
40
31
  title: "Flag a lead for human attention",
41
32
  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
33
  inputSchema: {
43
34
  ...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."),
35
+ leadId: NonEmptyString.annotate({ description: "The lead to flag." }),
36
+ reason: NonEmptyString.annotate({
37
+ description: "Why a human should look — shown to the coach verbatim.",
38
+ }),
39
+ ...idempotencyKeyInput("Reuse the same key when retrying this exact flag."),
53
40
  },
54
- readOnly: false,
55
- untrustedContent: false,
41
+ outputSchema: operationResult("leads.notes.add"),
56
42
  composes: ["leads.notes.add"],
57
- run: (api, operationById, input) => api.request(taskRequest(operationById("leads.notes.add"), input, {
43
+ run: (call, input) => call("leads.notes.add", {
58
44
  pathParams: { id: stringInput(input.leadId) },
59
45
  body: { body: `[needs-human] ${stringInput(input.reason)}` },
60
- })),
61
- };
62
- const draftReply = {
46
+ }),
47
+ });
48
+ const draftReply = taskTool({
63
49
  name: "luca_draft_reply",
64
50
  title: "Draft a reply for a lead",
65
51
  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
52
  inputSchema: {
67
53
  ...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."),
54
+ leadId: NonEmptyString.annotate({
55
+ description: "The lead to draft a reply for.",
56
+ }),
57
+ guidance: Schema.optionalKey(NonEmptyString.check(Schema.isMaxLength(500)).annotate({
58
+ description: "Optional steer for tone or content of the draft.",
59
+ })),
60
+ ...idempotencyKeyInput("Reuse the same key when retrying this exact draft."),
79
61
  },
80
- readOnly: false,
81
- untrustedContent: false,
62
+ outputSchema: operationResult("leads.draft"),
82
63
  composes: ["leads.draft"],
83
- run: (api, operationById, input) => api.request(taskRequest(operationById("leads.draft"), input, {
64
+ run: (call, input) => call("leads.draft", {
84
65
  pathParams: { id: stringInput(input.leadId) },
85
66
  body: textBody("guidance", input.guidance),
86
- })),
87
- };
88
- const approveAndSend = {
67
+ }),
68
+ });
69
+ const approveAndSend = taskTool({
89
70
  name: "luca_approve_and_send",
90
71
  title: "Approve a drafted reply and send it",
91
72
  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
73
  inputSchema: {
93
74
  ...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."),
75
+ reviewQueueId: NonEmptyString.annotate({
76
+ description: "The review-queue item to approve.",
77
+ }),
78
+ finalBody: Schema.optionalKey(NonEmptyString.annotate({
79
+ description: "Optional edited body to send instead of the drafted text.",
80
+ })),
81
+ ...idempotencyKeyInput("Reuse the same key when retrying this exact approval."),
108
82
  },
109
- readOnly: false,
110
- untrustedContent: false,
111
- confirmRequired: true,
83
+ outputSchema: operationResult("reviewQueue.approve"),
112
84
  composes: ["reviewQueue.approve"],
113
- run: (api, operationById, input) => api.request(taskRequest(operationById("reviewQueue.approve"), input, {
85
+ run: (call, input) => call("reviewQueue.approve", {
114
86
  pathParams: { id: stringInput(input.reviewQueueId) },
115
87
  body: textBody("finalBody", input.finalBody),
116
- })),
117
- };
88
+ }),
89
+ });
118
90
  export const leadTaskTools = [
119
91
  findLeads,
120
92
  flagForHuman,
@@ -1,7 +1,38 @@
1
- import { Effect, Option, Schema } from "effect";
2
- import * as z from "zod/v4";
3
- import { formatError } from "../errors.js";
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
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 { formatError, isAuthorizationFailure, } from "../errors.js";
9
+ import { NonEmptyString } from "../fields.js";
10
+ import { operationById, } from "../operations.js";
4
11
  import { optionalField } from "../optional-field.js";
12
+ /**
13
+ * Declares a task tool, inferring the id union from `composes` so `run` can
14
+ * only call what the tool declares.
15
+ */
16
+ export function taskTool(tool) {
17
+ return tool;
18
+ }
19
+ /**
20
+ * The `result` an operation tool declares, for a task tool that hands that
21
+ * response back unchanged or nests it in its own output.
22
+ */
23
+ export function operationResult(id) {
24
+ return operationById(id).outputSchema.result ?? Schema.Unknown;
25
+ }
26
+ /**
27
+ * A field that holds an operation's response, or the error a tool recorded in
28
+ * its place under `errorKey`.
29
+ */
30
+ export function resultOrError(id, errorKey) {
31
+ return Schema.Union([
32
+ operationResult(id),
33
+ Schema.Struct({ [errorKey]: Schema.String }),
34
+ ]);
35
+ }
5
36
  function workspaceOverride(input) {
6
37
  const override = {
7
38
  ...optionalField("workspaceId", input.workspaceId),
@@ -9,7 +40,7 @@ function workspaceOverride(input) {
9
40
  };
10
41
  // An override naming neither field is no override at all, and sending an
11
42
  // empty one would pin the request to nothing.
12
- if (Object.keys(override).length === 0) {
43
+ if (R.isEmptyReadonlyRecord(override)) {
13
44
  return;
14
45
  }
15
46
  return override;
@@ -18,7 +49,7 @@ function workspaceOverride(input) {
18
49
  * Assemble the request a task tool sends: the resolved operation plus its
19
50
  * path/query/body, with the workspace override and idempotency key folded in
20
51
  * 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
52
+ * `commonInput`. One seam owns "how a task request is built", so a tool body
22
53
  * reads as the fields it maps, not the plumbing. Pass `body: undefined` to omit
23
54
  * the body entirely (an absent optional field, never `{ body: undefined }`).
24
55
  */
@@ -35,8 +66,8 @@ export function taskRequest(operation, input, parts = {}) {
35
66
  /**
36
67
  * The only part of a listing page a composing tool reads for itself: the ids of
37
68
  * 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.
69
+ * intact for the caller, returned exactly as the API sent it, while the ids the
70
+ * tool acts on are validated rather than asserted.
40
71
  */
41
72
  const ListingIds = Schema.Struct({
42
73
  items: Schema.optional(Schema.Array(Schema.Struct({ id: Schema.optional(Schema.String) }))),
@@ -44,7 +75,7 @@ const ListingIds = Schema.Struct({
44
75
  const decodeListingIds = Schema.decodeUnknownOption(ListingIds);
45
76
  /**
46
77
  * 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
78
+ * cannot read, or whose items carry no id, contributes nothing. The tool still
48
79
  * returns the page itself rather than failing over a fan-out it cannot do.
49
80
  */
50
81
  export function listingIds(page, limit) {
@@ -66,27 +97,56 @@ export function listingIds(page, limit) {
66
97
  }
67
98
  /**
68
99
  * 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
100
+ * is absent or blank. Sending an empty string would mean something different to
70
101
  * the API than leaving the field off.
71
102
  */
72
103
  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);
104
+ return Option.fromNullishOr(value).pipe(Option.map(String), Option.filter((text) => Str.isNonEmpty(text)), Option.map((text) => ({ [key]: text })), Option.getOrUndefined);
74
105
  }
75
106
  /** Reads a numeric tool input, falling back when it is absent or not a number. */
76
107
  export function numberInput(value, fallback) {
77
- const parsed = z.number().safeParse(value);
78
- return parsed.success ? parsed.data : fallback;
108
+ return P.isNumber(value) ? value : fallback;
79
109
  }
80
- /** Required string arguments have already passed the registered tool schema. */
110
+ /**
111
+ * A required string argument. The registered tool schema has already checked
112
+ * it, so a non-string here is a bug in this package and dies as a defect.
113
+ */
81
114
  export function stringInput(value) {
82
- return z.string().parse(value);
115
+ return decodeString(value);
116
+ }
117
+ const decodeString = Schema.decodeUnknownSync(Schema.String);
118
+ /**
119
+ * The `idempotencyKey` input of a task tool that writes. An empty key is
120
+ * refused by the schema rather than read as "no key", because a keyed call
121
+ * skips checks a fresh call runs.
122
+ */
123
+ export function idempotencyKeyInput(description) {
124
+ return {
125
+ idempotencyKey: Schema.optionalKey(NonEmptyString.annotate({ description })),
126
+ };
127
+ }
128
+ /**
129
+ * A failure a partial-result tool can report in place of one section, or a
130
+ * failure that has to end the tool call. A refused token or a missing scope
131
+ * fails every other request in the tool too, and only a failed tool result
132
+ * carries the challenge that sends the client back through sign-in.
133
+ */
134
+ export const isPartialFailure = (error) => !isAuthorizationFailure(error);
135
+ /**
136
+ * One section of a partial-result tool, as a `Result` the tool folds into its
137
+ * report. An authorization failure stays in the error channel, see
138
+ * {@link isPartialFailure}.
139
+ */
140
+ export function sectionResult(effect) {
141
+ return effect.pipe(Effect.map((value) => Result.succeed(value)), Effect.catchIf(isPartialFailure, (error) => Effect.succeed(Result.fail(error))));
83
142
  }
84
143
  /**
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.
144
+ * A request whose failure comes back as a field under `errorKey` instead of
145
+ * ending the tool call. A tool that files two independent writes keeps the
146
+ * second one reachable when the first fails, so the agent reads what failed
147
+ * instead of retrying the half that already landed. An authorization failure
148
+ * still ends the call, see {@link isPartialFailure}.
89
149
  */
90
- export function independentWrite(errorKey, effect) {
91
- return Effect.catchAll(effect, (error) => Effect.succeed({ [errorKey]: formatError(error) }));
150
+ export function failureAsField(errorKey, effect) {
151
+ return Effect.catchIf(effect, isPartialFailure, (error) => Effect.succeed({ [errorKey]: formatError(error) }));
92
152
  }
@@ -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`.
@@ -0,0 +1,42 @@
1
+ import * as Duration from "effect/Duration";
2
+ import * as Effect from "effect/Effect";
3
+ import { TokenVerificationUnavailable } from "./errors.js";
4
+ /**
5
+ * The longest a verification call may run. It sits in front of every remote
6
+ * request, so a slow API answers 503 within seconds instead of holding the
7
+ * client for the full API timeout. A shorter configured timeout still wins.
8
+ */
9
+ const VERIFY_TIMEOUT_MS = 5000;
10
+ export function unavailable(message) {
11
+ return (cause) => new TokenVerificationUnavailable({ message, cause });
12
+ }
13
+ /** SHA-256 of a secret as hex, so a limiter or cache never stores it. */
14
+ export const sha256Hex = (value) => Effect.tryPromise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))).pipe(Effect.map((digest) => Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")));
15
+ /**
16
+ * One bounded call to the Luca API that carries a token, with `read` turning
17
+ * the response into the verdict. Timeout but no retry: this is on the auth
18
+ * path of every request, and the client retries a 503 on its own schedule. A
19
+ * redirect is an error, because it would carry the token to a host we never
20
+ * named.
21
+ */
22
+ export function callApi(settings, fetchImpl, request, read) {
23
+ const controller = new AbortController();
24
+ const timeoutMs = Math.min(settings.requestTimeoutMs ?? VERIFY_TIMEOUT_MS, VERIFY_TIMEOUT_MS);
25
+ return Effect.tryPromise({
26
+ try: () => fetchImpl(`${settings.apiBaseUrl}${request.path}`, {
27
+ ...request.init,
28
+ redirect: "error",
29
+ signal: controller.signal,
30
+ }),
31
+ catch: unavailable("Could not reach token verification"),
32
+ }).pipe(Effect.flatMap(read), Effect.timeoutOrElse({
33
+ duration: Duration.millis(timeoutMs),
34
+ orElse: () => Effect.fail(new TokenVerificationUnavailable({
35
+ message: `Token verification did not answer within ${timeoutMs}ms`,
36
+ })),
37
+ }),
38
+ // The signal belongs to the whole exchange, body read included, so abort
39
+ // on every exit: a timed-out or unread response cannot outlive the auth
40
+ // decision it was made for.
41
+ Effect.ensuring(Effect.sync(() => controller.abort())));
42
+ }
@@ -1,3 +1,4 @@
1
+ import * as Arr from "effect/Array";
1
2
  function cache() {
2
3
  let entries = new WeakMap();
3
4
  return {
@@ -33,7 +34,7 @@ export function buildCache() {
33
34
  * changed underneath it, which is exactly what a mutation run does.
34
35
  */
35
36
  export function clearToolBuildCache() {
36
- for (const reset of resets) {
37
+ Arr.forEach(resets, (reset) => {
37
38
  reset();
38
- }
39
+ });
39
40
  }